From 8bb6a9c74728e52e79838d594e6d71bb5748df93 Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Tue, 11 Aug 2026 17:48:30 +0300 Subject: [PATCH 01/39] Redesign hx-live state access --- src/ext/hx-live.js | 401 ++++++++++--- test/tests/ext/hx-live.js | 1148 ++++++++++++++++++++++++++++++++----- 2 files changed, 1334 insertions(+), 215 deletions(-) diff --git a/src/ext/hx-live.js b/src/ext/hx-live.js index fbbef26e1..1c3b6beb0 100644 --- a/src/ext/hx-live.js +++ b/src/ext/hx-live.js @@ -74,78 +74,56 @@ 'multiple','autofocus','novalidate','default','reversed', 'loop','muted','controls','autoplay','playsinline', 'formnovalidate','async','defer','ismap','typemustmatch', - 'allowfullscreen','itemscope','nomodule' + 'allowfullscreen','itemscope','nomodule','checked','selected' ]); - let PROPERTY_ATTRS = new Set(['checked','value','selected']); + let PROPERTY_BINDING_ATTRS = new Set(['checked','value','selected']); let STRINGY_BOOLEAN_ATTRS = new Set(['contenteditable','draggable','spellcheck']); + let NUMERIC_INPUT_TYPES = new Set(['number', 'range']); + let NUMERIC_ATTRS = new Set([ + 'tabindex','colspan','rowspan','maxlength','minlength', + 'size','span','start','rows','cols','width','height' + ]); /** - * Get or set an attribute, class, or property-backed value on one or more elements. + * Get or set an attribute or property-backed value on one or more elements. * * @param {Element[]} elts - Target elements. - * @param {string} name - Class (`.foo`), `'class'`, or attribute name. + * @param {string} name - 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('class', 'foo bar') // raw class attribute + * attr('aria-expanded', open) // ARIA: raw string value + * attr('value', 'hello') // set the value 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 (name === 'value' && NUMERIC_INPUT_TYPES.has(e.type)) { + return e.value === '' ? null : e.valueAsNumber; + } + if (PROPERTY_BINDING_ATTRS.has(name)) return e[name]; if (BOOLEAN_ATTRS.has(name)) return e.hasAttribute(name); - if (isPropAttr) return e[name]; - return e.getAttribute(name); + let raw = e.getAttribute(name); + if (NUMERIC_ATTRS.has(name) && raw?.trim() && Number.isFinite(Number(raw))) return Number(raw); + return raw; } 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)); - } + if (isAria) { + if (value == null) e.removeAttribute(name); + else e.setAttribute(name, String(value)); + } else if (PROPERTY_BINDING_ATTRS.has(name)) { + applyPropertyBinding(e, name, value); } else if (BOOLEAN_ATTRS.has(name)) { if (value) e.setAttribute(name, ''); else e.removeAttribute(name); @@ -155,12 +133,47 @@ 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); + if (value === null || value === undefined) e.removeAttribute(name); else e.setAttribute(name, value === true ? '' : String(value)); } } } + function eachTarget(elts, findOwner, fallback, fn) { + let seen = new Set(); + for (let elt of elts) { + let target = findOwner(elt) || (fallback ? elt : null); + if (target && !seen.has(target)) { + seen.add(target); + fn(target); + } + } + } + + function makeAttrProxy(elts, cascades, scope) { + let findOwner = (elt, name) => cascades + ? elt.closest('[' + CSS.escape(name) + ']') + : elt; + return new Proxy({}, { + get: (_, name) => { + if (name === 'data' || name === 'aria' || name === 'class') return scope[name]; + if (typeof name !== 'string') return undefined; + let owner = elts[0] && findOwner(elts[0], name); + return owner ? applyAttr([owner], name) : undefined; + }, + set: (_, name, value) => { + if (typeof name !== 'string') return false; + eachTarget(elts, elt => findOwner(elt, name), true, elt => applyAttr([elt], name, value)); + return true; + }, + deleteProperty: (_, name) => { + if (typeof name !== 'string') return false; + eachTarget(elts, elt => findOwner(elt, name), false, elt => applyAttr([elt], name, null)); + return true; + } + }); + } + function applyStyleBinding(elt, value) { let prop = api.htmxProp(elt); let oldManaged = prop.liveStyles || new Set(); @@ -194,15 +207,184 @@ return s.replace(/[A-Z]/g, m => '-' + m.toLowerCase()); } + let booleanAria = new Set([ + 'atomic', + 'busy', + 'checked', + 'current', + 'disabled', + 'expanded', + 'grabbed', + 'haspopup', + 'hidden', + 'invalid', + 'modal', + 'multiline', + 'multiselectable', + 'pressed', + 'readonly', + 'required', + 'selected' + ]); + let integerAria = new Set([ + 'colcount', + 'colindex', + 'colspan', + 'level', + 'posinset', + 'rowcount', + 'rowindex', + 'rowspan', + 'setsize' + ]); + let numberAria = new Set([ + 'valuemax', + 'valuemin', + 'valuenow' + ]); + let listAria = new Set([ + 'controls', + 'describedby', + 'dropeffect', + 'flowto', + 'labelledby', + 'owns', + 'relevant' + ]); + function writeClass(elt, name, value) { + elt.classList.toggle(name, !!value); + if (!elt.classList.length) elt.removeAttribute('class'); + } + + let CLASS_WRITE_METHODS = new Set(['add', 'remove', 'toggle', 'replace']); + + function makeClassProxy(elts) { + let first = elts[0]; + let write = (name, value) => { for (let e of elts) writeClass(e, name, value); }; + return new Proxy({}, { + get: (_, name) => { + if (typeof name !== 'string' || !first) return undefined; + if (name === 'assign') { + return value => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + console.warn(`htmx: class.assign expects an object, got ${Array.isArray(value) ? 'array' : typeof value}.`, { elts }); + return; + } + for (let e of elts) writeClasses(e, value); + }; + } + let m = first.classList[name]; + if (typeof m === 'function') { + return elts.length === 1 || !CLASS_WRITE_METHODS.has(name) + ? m.bind(first.classList) + : (...args) => { for (let e of elts) e.classList[name](...args); }; + } + return first.classList.contains(name); + }, + set: (_, name, value) => { + if (typeof name !== 'string') return false; + write(name, value); + return true; + }, + deleteProperty: (_, name) => { + if (typeof name !== 'string') return false; + write(name, false); + return true; + }, + has: (_, name) => typeof name === 'string' && !!first && first.classList.contains(name), + ownKeys: () => first ? [...first.classList] : [], + getOwnPropertyDescriptor: (_, name) => first && first.classList.contains(name) + ? { enumerable: true, configurable: true } + : undefined + }); + } + + function makeClosestClassProxy(elts) { + let owner = (elt, name) => elt.closest('.' + CSS.escape(name)); + return new Proxy({}, { + get: (_, name) => typeof name === 'string' && !!elts[0] ? !!owner(elts[0], name) : undefined, + set: (_, name, value) => { + if (typeof name !== 'string') return false; + eachTarget(elts, elt => owner(elt, name), true, elt => writeClass(elt, name, value)); + return true; + }, + deleteProperty: (_, name) => { + if (typeof name !== 'string') return false; + eachTarget(elts, elt => owner(elt, name), false, elt => writeClass(elt, name, false)); + return true; + } + }); + } + + function makeStateScope(elts, cascades) { + let data, aria, classes, attr; + let scope = { + get data() { return data ||= makeDataProxy(elts, cascades); }, + get aria() { return aria ||= makeAriaProxy(elts, cascades); }, + get class() { return classes ||= cascades ? makeClosestClassProxy(elts) : makeClassProxy(elts); }, + get attr() { return attr ||= makeAttrProxy(elts, cascades, scope); } + }; + return scope; + } + + 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 makeAriaProxy(elts, cascades = true) { + let findOwner = (elt, name) => cascades + ? elt.closest('[' + name + ']') + : elt.hasAttribute(name) ? elt : null; + return new Proxy({}, { + get: (_, prop) => { + if (typeof prop !== 'string') return undefined; + let key = prop.toLowerCase(); + let name = 'aria-' + key; + let owner = elts[0] && findOwner(elts[0], name); + let value = owner?.getAttribute(name); + if (booleanAria.has(key) && (value === 'true' || value === 'false')) return value === 'true'; + let number = Number(value); + let validNumber = numberAria.has(key) || (integerAria.has(key) && Number.isInteger(number)); + if (validNumber && value?.trim() && Number.isFinite(number)) return number; + if (listAria.has(key) && value != null) return value.trim() ? value.trim().split(/\s+/) : []; + return value; + }, + set: (_, prop, value) => { + if (typeof prop !== 'string') return false; + let key = prop.toLowerCase(); + let name = 'aria-' + key; + eachTarget(elts, elt => findOwner(elt, name), true, elt => writeAria(elt, key, value)); + return true; + }, + deleteProperty: (_, prop) => { + if (typeof prop !== 'string') return false; + let key = prop.toLowerCase(); + let name = 'aria-' + key; + eachTarget(elts, elt => findOwner(elt, name), false, elt => elt.removeAttribute(name)); + return true; + } + }); + } + + function writeData(elt, name, value) { + if (value === undefined) elt.removeAttribute(name); + else elt.setAttribute(name, typeof value === 'string' ? value : JSON.stringify(value)); + } + // `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) { + function makeDataProxy(elts, cascades = true) { + let findOwner = (elt, kebab) => cascades + ? elt.closest('[data-' + kebab + ']') + : elt.hasAttribute('data-' + kebab) ? elt : null; return new Proxy({}, { get: (_, prop) => { if (typeof prop !== 'string') return undefined; let kebab = camelToKebab(prop); - let ancestor = elt.closest('[data-' + kebab + ']'); + let ancestor = elts[0] && findOwner(elts[0], kebab); if (!ancestor) return undefined; let raw = ancestor.dataset[prop]; try { return JSON.parse(raw); } catch { return raw; } @@ -210,19 +392,26 @@ 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); + let name = 'data-' + kebab; + eachTarget(elts, elt => findOwner(elt, kebab), true, elt => writeData(elt, name, val)); + return true; + }, + deleteProperty: (_, prop) => { + if (typeof prop !== 'string') return false; + let kebab = camelToKebab(prop); + let name = 'data-' + kebab; + eachTarget(elts, elt => findOwner(elt, kebab), false, elt => elt.removeAttribute(name)); return true; }, has: (_, prop) => { if (typeof prop !== 'string') return false; let kebab = camelToKebab(prop); - return !!elt.closest('[data-' + kebab + ']'); + return !!elts[0] && !!findOwner(elts[0], kebab); }, ownKeys: () => { let result = []; let seen = new Set(); - for (let node = elt; node; node = node.parentElement) { + for (let node = elts[0]; node; node = cascades ? node.parentElement : null) { for (let key of Object.keys(node.dataset)) { if (key !== 'htmxPowered' && !seen.has(key)) { seen.add(key); @@ -235,31 +424,59 @@ getOwnPropertyDescriptor: (_, prop) => { if (typeof prop !== 'string' || prop === 'htmxPowered') return; let kebab = camelToKebab(prop); - if (elt.closest('[data-' + kebab + ']')) return { enumerable: true, configurable: true }; + if (elts[0] && findOwner(elts[0], kebab)) return { enumerable: true, configurable: true }; } }); } - function applyMultiClass(elt, value) { - let prop = api.htmxProp(elt); - let oldManaged = prop.liveClasses || new Set(); - let newManaged = new Set(); + 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(elt, value) { + let written = []; if (typeof value === 'string') { for (let c of value.trim().split(/\s+/).filter(Boolean)) { - newManaged.add(c); - elt.classList.add(c); + written.push(c); + writeClass(elt, c, true); } } 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); + written.push(c); + writeClass(elt, c, cond); } } } - 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(elt, value)); + for (let c of oldManaged) if (!newManaged.has(c)) writeClass(elt, c, false); prop.liveClasses = newManaged; } @@ -317,25 +534,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]; + let asArray = list && (typeof list === 'string' + ? list.split('|').map(v => v.trim()) + : list); if (!asArray) { if (isClass) element.classList.toggle(key); @@ -451,6 +670,7 @@ 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,14 +684,13 @@ 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 (p === 'toggle') return (name, ...values) => { elts.forEach(e => applyToggle(e, name, ...values)); return proxy; }; + if (p === 'attr') return (local ||= makeStateScope(elts, false)).attr; + if (p === 'data') return elts[0] ? (local ||= makeStateScope(elts, false)).data : undefined; + if (p === 'class') return (local ||= makeStateScope(elts, false)).class; + if (p === 'closest') return elts[0] ? closest ||= makeStateScope(elts, true) : undefined; if (arrayMethods.has(p)) return elts[p].bind(elts); + if (p === 'aria') return elts[0] ? (local ||= makeStateScope(elts, false)).aria : undefined; 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])); @@ -609,9 +828,16 @@ 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; + if (attrName === 'class' || attrName.startsWith('.')) { + applyClassBinding(elt, attrName, value); + return; + } + if (PROPERTY_BINDING_ATTRS.has(attrName)) { + applyPropertyBinding(elt, attrName, value); + return; + } + // Always write aria-* attrs because their getter and setter types differ. + if (!attrName.startsWith('aria-') && applyAttr([elt], attrName) === value) return; applyAttr([elt], attrName, value); } @@ -625,7 +851,7 @@ 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)), + toggle: (target, name, ...values) => [...asTargets(target)].forEach(e => applyToggle(e, name, ...values)), attr: (target, name, ...rest) => applyAttr([...asTargets(target)], name, ...rest), forEvent: (...args) => forEvent(null, ...args), nextFrame: () => new Promise(r => requestAnimationFrame(r)) @@ -652,6 +878,8 @@ if (--swaps === 0 && fns.size > 0) schedule(); }, htmx_scope: (elt, detail) => { + let local = makeStateScope([elt], false); + let closest = makeStateScope([elt], true); Object.assign(detail.scope, { q: makeQ(elt), forEvent: (...args) => forEvent(elt, ...args), @@ -659,13 +887,14 @@ 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), + 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, - classList: elt.classList, - data: makeDataProxy(elt) + data: closest.data, + aria: local.aria, + closest }); if (htmx.config.live?.useDollar) detail.scope.$ = detail.scope.q; } diff --git a/test/tests/ext/hx-live.js b/test/tests/ext/hx-live.js index b69617373..9d7816ea2 100644 --- a/test/tests/ext/hx-live.js +++ b/test/tests/ext/hx-live.js @@ -469,7 +469,7 @@ describe('hx-live extension', function () {
- + `; htmx.process(playground()); @@ -510,6 +510,7 @@ describe('hx-live extension', function () { it('q returns 0-count proxy when no match', function() { let proxy = htmx.live.q('.does-not-exist-anywhere'); proxy.count.should.equal(0); + assert.isUndefined(proxy.aria); }); it('q(element) wraps a single element', function() { @@ -910,6 +911,40 @@ describe('hx-live extension', function () { div.getAttribute('data-mode').should.equal('light'); }); + it('toggle(name, "a", "b", "c") cycles attribute through values (variadic form)', function() { + playground().innerHTML = '
'; + let div = playground().querySelector('div'); + let p = htmx.live.q('div'); + p.toggle('data-mode', 'light', 'dark', 'auto'); + div.getAttribute('data-mode').should.equal('light'); + p.toggle('data-mode', 'light', 'dark', 'auto'); + div.getAttribute('data-mode').should.equal('dark'); + p.toggle('data-mode', 'light', 'dark', 'auto'); + div.getAttribute('data-mode').should.equal('auto'); + p.toggle('data-mode', 'light', 'dark', 'auto'); + div.getAttribute('data-mode').should.equal('light'); + }); + + it('toggle(name, "v", "") cycles between value and absent (variadic form)', function() { + playground().innerHTML = '
'; + let div = playground().querySelector('div'); + let p = htmx.live.q('div'); + p.toggle('data-state', 'on', ''); + div.getAttribute('data-state').should.equal('on'); + p.toggle('data-state', 'on', ''); + div.hasAttribute('data-state').should.equal(false); + }); + + it('htmx.live.toggle(target, name, "a", "b") cycles across matches', function() { + playground().innerHTML = '
'; + htmx.live.toggle('.t', 'data-view', 'grid', 'list'); + [...playground().querySelectorAll('.t')].map(e => e.getAttribute('data-view')) + .should.deep.equal(['grid', 'grid']); + htmx.live.toggle('.t', 'data-view', 'grid', 'list'); + [...playground().querySelectorAll('.t')].map(e => e.getAttribute('data-view')) + .should.deep.equal(['list', 'list']); + }); + it('toggle(name, [array]) cycles attribute through values (array form)', function() { playground().innerHTML = '
'; let div = playground().querySelector('div'); @@ -999,16 +1034,196 @@ describe('hx-live extension', function () { assert.isFunction(htmx.live.toggle); }); - it('classList scope helper accesses this.classList', function() { - playground().innerHTML = ` - + it('class reads, writes, and deletes class state', function() { + let button = createProcessedHTML(` + + `); + button.click(); + window.__classState.should.deep.equal([true, false]); + button.classList.contains('pending').should.equal(false); + button.classList.contains('done').should.equal(true); + button.classList.contains('is-active').should.equal(true); + button.classList.contains('remove-me').should.equal(false); + delete window.__classState; + }); + + it("toggle('.name') toggles membership", function() { + let button = createProcessedHTML(` + + `); + button.click(); + button.classList.contains('active').should.equal(true); + button.click(); + button.classList.contains('active').should.equal(false); + }); + + it("take('.name') moves membership between siblings", 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); + let buttons = playground().querySelectorAll('button'); + buttons[1].click(); + buttons[0].classList.contains('active').should.equal(false); + buttons[1].classList.contains('active').should.equal(true); + }); + + it('q().class accesses only the first matched element', function() { + playground().innerHTML = ''; + let classes = htmx.live.q('#one').class; + classes.active = true; + classes.active.should.equal(true); + playground().querySelector('#one').classList.contains('active').should.equal(true); + playground().querySelector('#two').classList.contains('active').should.equal(false); + }); + + it('class supports keys and object spread', function() { + playground().innerHTML = ''; + let classes = htmx.live.q('#one').class; + Object.keys(classes).should.deep.equal(['active', 'pending']); + ({ ...classes }).should.deep.equal({ active: true, pending: true }); + }); + + it('class methods delegate to classList: add, remove, toggle, replace, contains', function() { + let button = createProcessedHTML(` + + `); + button.click(); + window.__r.should.deep.equal([true, false, true, false, true, false, false, true]); + delete window.__r; + }); + + it('class.assign adds truthy, removes falsy, leaves unmentioned', function() { + let button = createProcessedHTML(` + + `); + button.click(); + button.classList.contains('active').should.equal(true); + button.classList.contains('loading').should.equal(false); + button.classList.contains('keep').should.equal(true); + }); + + it('class.assign warns and no-ops on non-object arguments', function() { + let warnings = []; + let realWarn = console.warn; + console.warn = (...args) => warnings.push(args[0]); + try { + let button = createProcessedHTML(` + + `); + button.click(); + button.classList.contains('active').should.equal(false); + button.classList.contains('keep').should.equal(true); + } finally { + console.warn = realWarn; + } + warnings.length.should.equal(2); + warnings[0].should.contain('class.assign expects an object'); + }); + + it('removing the last class removes the class attribute', function() { + let button = createProcessedHTML(` + + `); + button.click(); + button.classList.length.should.equal(0); + button.hasAttribute('class').should.equal(false); + }); + + it('reserved method names: writes make classes, reads return methods, in sees classes', function() { + let button = createProcessedHTML(` + + `); + button.click(); + button.classList.contains('toggle').should.equal(false); // delete removed it + window.__kind.should.equal('function'); // read is the method + delete window.__kind; + + let classes = htmx.live.q('#res').class; + ('toggle' in classes).should.equal(false); // has-trap reads classes only + classes.toggle = true; // key write adds the class + button.classList.contains('toggle').should.equal(true); + ('toggle' in classes).should.equal(true); // in sees it once it is a class + (typeof classes.toggle).should.equal('function'); // read still returns the method + }); + + it('q().class writes hit all matches, reads use the first', function() { + playground().innerHTML = '
'; + let classes = htmx.live.q('.x in #pl').class; + classes.add('a'); + let divs = playground().querySelectorAll('#pl .x'); + divs[0].classList.contains('a').should.equal(true); + divs[1].classList.contains('a').should.equal(true); + + classes.assign({ a: false, b: true }); + divs[0].classList.contains('a').should.equal(false); + divs[0].classList.contains('b').should.equal(true); + divs[1].classList.contains('a').should.equal(false); + divs[1].classList.contains('b').should.equal(true); + + classes.contains('a').should.equal(false); // reads first match + classes.contains('b').should.equal(true); + }); + + it('class proxy: symbols are undefined, spread skips method names', function() { + playground().innerHTML = ''; + let classes = htmx.live.q('#sp').class; + assert.isUndefined(classes[Symbol.iterator]); + assert.isUndefined(classes[Symbol.toPrimitive]); + Object.keys(classes).should.deep.equal(['active', 'pending']); + ({ ...classes }).should.deep.equal({ active: true, pending: true }); + }); + + it('hx-on:click class.add and class.assign work end-to-end', function() { + let button = createProcessedHTML(` + + `); + button.click(); + button.classList.contains('keep').should.equal(true); + button.classList.contains('spin').should.equal(true); + button.classList.contains('active').should.equal(true); + button.classList.contains('loading').should.equal(false); + }); + + it('class bindings react to class state', async function() { + let elt = createProcessedHTML(` +
+ `); + elt.classList.contains('visible').should.equal(true); + elt.classList.remove('selected'); + await htmx.timeout(5); + elt.classList.contains('visible').should.equal(false); }); it('htmx.live.toggle(target, name) toggles across matches', function() { @@ -1114,16 +1329,21 @@ describe('hx-live extension', function () { htmx.live.attr('#b', 'disabled').should.equal(false); }); - 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('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() getter: ARIA returns raw strings or null', function() { + playground().innerHTML = ` +
+ +
+
+
+
+ `; + htmx.live.attr('#a', 'aria-expanded').should.equal('true'); + htmx.live.attr('#b', 'aria-expanded').should.equal('false'); + htmx.live.attr('#c', 'aria-current').should.equal('page'); + htmx.live.attr('#d', 'aria-valuenow').should.equal('50'); + htmx.live.attr('#e', 'aria-controls').should.equal('menu help'); + assert.isNull(htmx.live.attr('#f', 'aria-label')); }); it('attr() getter: class returns full class string', function() { @@ -1137,16 +1357,16 @@ describe('hx-live extension', function () { assert.isNull(htmx.live.attr('#b', 'data-x')); }); - it('attr() getter: checked returns property value', function() { - playground().innerHTML = ''; + it('attr() getter: checked returns live state', function() { + playground().innerHTML = ''; + let inp = playground().querySelector('#a'); + inp.checked = true; htmx.live.attr('#a', 'checked').should.equal(true); - htmx.live.attr('#b', 'checked').should.equal(false); }); - it('attr() getter: value returns property value', function() { + it('attr() getter: value returns live state', 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'); }); @@ -1159,16 +1379,15 @@ describe('hx-live extension', function () { playground().querySelector('#a').hasAttribute('disabled').should.equal(false); }); - it('attr() setter: ARIA writes "true"/"false", never removes', function() { + it('attr() setter: ARIA stringifies values and null 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'); + div.hasAttribute('aria-expanded').should.equal(false); }); it('attr() setter: aria-* strings and numbers pass through', function() { @@ -1183,74 +1402,32 @@ describe('hx-live extension', function () { 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('attr() setter: class (string) sets managed class list', function() { + it('attr() treats class as a raw attribute', 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); - - // 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); - }); - - 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); - - // 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); + div.getAttribute('class').should.equal('foo bar'); }); - 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('attr() setter: checked syncs property and attribute', function() { + it('attr() setter: checked changes attribute and live state together', function() { playground().innerHTML = ''; let inp = playground().querySelector('#a'); + inp.checked = false; 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); + inp.checked.should.equal(true); }); - it('attr() setter: value syncs property and attribute', function() { - playground().innerHTML = ''; + it('attr() setter: value changes the attribute and live state together', 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'); + inp.value = 'live'; + htmx.live.attr('#a', 'value', 'set'); + inp.getAttribute('value').should.equal('set'); + inp.value.should.equal('set'); htmx.live.attr('#a', 'value', null); - inp.value.should.equal(''); inp.hasAttribute('value').should.equal(false); + inp.value.should.equal(''); }); it('attr() setter: regular attr null removes', function() { @@ -1289,41 +1466,235 @@ describe('hx-live extension', function () { playground().querySelector('#a').hasAttribute('contenteditable').should.equal(false); }); - it('q().attr() applies setter to all matched elements', function() { + it('q().attr applies setter to all matched elements', function() { playground().innerHTML = ''; - htmx.live.q('.x').attr('disabled', true); + htmx.live.q('.x').attr.disabled = true; let inputs = playground().querySelectorAll('.x'); for (let inp of inputs) inp.hasAttribute('disabled').should.equal(true); }); - it('q().attr() getter returns from first matched element', function() { + it('q().attr getter returns from first matched element', function() { playground().innerHTML = '
'; - htmx.live.q('.x').attr('data-i').should.equal('a'); + htmx.live.q('.x').attr['data-i'].should.equal('a'); }); - 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('q().attr removes an attribute with delete', function() { + playground().innerHTML = ''; + delete htmx.live.q('.x').attr.role; + playground().querySelector('.x').hasAttribute('role').should.equal(false); }); - it('attr() is available in hx-on scope bound to element', function() { - playground().innerHTML = ''; + it('q().attr.data and q().data share the local data view', function() { + playground().innerHTML = ` +
+
+ `; + let proxy = htmx.live.q('.x'); + assert.strictEqual(proxy.attr.data, proxy.data); + proxy.attr.data.count.should.equal(1); + proxy.data.count.should.equal(1); + + proxy.attr.data.count = 3; + [...playground().querySelectorAll('.x')].map(e => e.dataset.count) + .should.deep.equal(['3', '3']); + + proxy.data.count = 4; + [...playground().querySelectorAll('.x')].map(e => e.dataset.count) + .should.deep.equal(['4', '4']); + }); + + it('q().class and q().attr.class share the local class view', function() { + playground().innerHTML = '
'; + let proxy = htmx.live.q('.x'); + assert.strictEqual(proxy.attr.class, proxy.class); + proxy.attr.class.active = true; + [...playground().querySelectorAll('.x')].every(e => e.classList.contains('active')) + .should.equal(true); + proxy.class.active = false; + [...playground().querySelectorAll('.x')].some(e => e.classList.contains('active')) + .should.equal(false); + }); + + it('q().aria and q().attr.aria share the local ARIA view', function() { + playground().innerHTML = '
'; + let proxy = htmx.live.q('.x'); + assert.strictEqual(proxy.attr.aria, proxy.aria); + proxy.attr.aria.busy = true; + [...playground().querySelectorAll('.x')].map(e => e.getAttribute('aria-busy')) + .should.deep.equal(['true', 'true']); + proxy.aria.busy = false; + [...playground().querySelectorAll('.x')].map(e => e.getAttribute('aria-busy')) + .should.deep.equal(['false', 'false']); + }); + + it('q().data is local while bare data resolves the nearest owner', function() { + playground().innerHTML = ` +
+ +
+ `; + htmx.process(playground()); + let button = playground().querySelector('#button'); + button.click(); + window.__dataScopes.should.deep.equal(['owner', undefined]); + button.hasAttribute('data-state').should.equal(false); + playground().querySelector('section').dataset.state.should.equal('changed'); + delete window.__dataScopes; + }); + + it('q().closest resolves one data owner per selected element and deduplicates writes', function() { + playground().innerHTML = ` +
+ + +
+
+ +
+ `; + let a = playground().querySelector('#a'); + let b = playground().querySelector('#b'); + let writes = new Map([[a, 0], [b, 0]]); + let setAttribute = Element.prototype.setAttribute; + Element.prototype.setAttribute = function(name, value) { + if (name === 'data-state' && writes.has(this)) writes.set(this, writes.get(this) + 1); + return setAttribute.call(this, name, value); + }; + try { + htmx.live.q(playground().querySelectorAll('.hx-live-owner-item')).closest.data.state = 'open'; + } finally { + Element.prototype.setAttribute = setAttribute; + } + a.dataset.state.should.equal('open'); + b.dataset.state.should.equal('open'); + writes.get(a).should.equal(1); + writes.get(b).should.equal(1); + }); + + it('q().closest resolves each selected element for attributes, ARIA, and classes', function() { + playground().innerHTML = ` +
+ + +
+
+ +
+ `; + let proxy = htmx.live.q(playground().querySelectorAll('.hx-live-owner-item')); + proxy.closest.attr.disabled = false; + proxy.closest.aria.busy = true; + proxy.closest.class.active = false; + + [...playground().querySelectorAll('section')].every(e => !e.hasAttribute('disabled')) + .should.equal(true); + [...playground().querySelectorAll('section')].map(e => e.getAttribute('aria-busy')) + .should.deep.equal(['true', 'true']); + [...playground().querySelectorAll('section')].every(e => !e.classList.contains('active')) + .should.equal(true); + }); + + it('q().closest defers owner lookup until a state key is accessed', function() { + playground().innerHTML = '
'; + let proxy = htmx.live.q(playground().querySelectorAll('.hx-live-owner-item')); + let calls = 0; + let closest = Element.prototype.closest; + Element.prototype.closest = function(...args) { + calls++; + return closest.apply(this, args); + }; + try { + let scope = proxy.closest; + let data = scope.data; + calls.should.equal(0); + data.state.should.equal('owner'); + calls.should.equal(1); + } finally { + Element.prototype.closest = closest; + } + }); + + it('q().closest reads the first selected owner for each state namespace', function() { + playground().innerHTML = ` + +
+ +
+ `; + let proxy = htmx.live.q(playground().querySelectorAll('.hx-live-owner-read-item')); + let scope = proxy.closest; + assert.strictEqual(scope, proxy.closest); + assert.strictEqual(scope.attr.data, scope.data); + assert.strictEqual(scope.attr.aria, scope.aria); + assert.strictEqual(scope.attr.class, scope.class); + assert.strictEqual(scope.data.state, 'first'); + assert.strictEqual(scope.aria.busy, false); + assert.strictEqual(scope.attr.role, 'tab'); + assert.strictEqual(scope.class.active, true); + }); + + it('q().closest reads undefined when no data owner exists', function() { + playground().innerHTML = ''; + playground().removeAttribute('data-state'); + assert.isUndefined(htmx.live.q(playground().querySelectorAll('.hx-live-owner-item')).closest.data.state); + }); + + it('q().closest writes data locally for every match when no owner exists', function() { + playground().innerHTML = ''; + playground().removeAttribute('data-state'); + htmx.live.q(playground().querySelectorAll('.hx-live-owner-item')).closest.data.state = 'created'; + [...playground().querySelectorAll('.hx-live-owner-item')].map(e => e.dataset.state) + .should.deep.equal(['created', 'created']); + }); + + it('q().closest writes attr and class locally for every match when no owner exists', function() { + playground().innerHTML = ''; + let proxy = htmx.live.q(playground().querySelectorAll('.hx-live-owner-item')); + proxy.closest.attr.role = 'button'; + proxy.closest.class.active = true; + + [...playground().querySelectorAll('.hx-live-owner-item')].every(e => e.getAttribute('role') === 'button') + .should.equal(true); + [...playground().querySelectorAll('.hx-live-owner-item')].every(e => e.classList.contains('active')) + .should.equal(true); + }); + + it('q().closest deletes nothing when no owner exists', function() { + playground().innerHTML = ''; + playground().removeAttribute('data-state'); + let proxy = htmx.live.q(playground().querySelectorAll('.hx-live-owner-item')); + delete proxy.closest.data.state; + delete proxy.closest.aria.busy; + delete proxy.closest.attr.role; + delete proxy.closest.class.active; + + [...playground().querySelectorAll('.hx-live-owner-item')].every(e => + !e.hasAttribute('data-state') && + !e.hasAttribute('aria-busy') && + !e.hasAttribute('role') && + !e.classList.contains('active')) + .should.equal(true); + }); + + 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('attr() in hx-live expression operates on current element', async function() { + 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); + elt.hasAttribute('data-flipped').should.equal(true); }); // ------------------------------------------------------------------------- @@ -1355,14 +1726,490 @@ describe('hx-live extension', function () { assert.isFunction(htmx.live.attr); }); + // ------------------------------------------------------------------------- + // cascading ARIA proxy + // ------------------------------------------------------------------------- + + it('aria.foo reacts to the closest ARIA state', async function() { + playground().innerHTML = ` +
+
+ +
+
+ `; + htmx.process(playground()); + let button = playground().querySelector('button'); + button.disabled.should.equal(false); + button.click(); + await htmx.timeout(5); + button.disabled.should.equal(true); + playground().querySelector('form').getAttribute('aria-busy').should.equal('true'); + playground().querySelector('section').getAttribute('aria-busy').should.equal('true'); + }); + + it("toggle('aria-name', values) cycles explicit values", function() { + playground().innerHTML = ` +
+ +
+ `; + htmx.process(playground()); + let owner = playground().querySelector('div'); + let button = playground().querySelector('button'); + button.click(); + owner.getAttribute('aria-sort').should.equal('descending'); + button.click(); + owner.getAttribute('aria-sort').should.equal('other'); + }); + + it("take('aria-name') claims sibling state", function() { + playground().innerHTML = ` +
+ + +
+ `; + htmx.process(playground()); + let tabs = playground().querySelectorAll('[role=tab]'); + tabs[1].click(); + tabs[0].getAttribute('aria-selected').should.equal('false'); + tabs[1].getAttribute('aria-selected').should.equal('true'); + }); + + it('q().aria uses only its first match', function() { + playground().innerHTML = ` +
+
+
+ `; + let aria = htmx.live.q('#form').aria; + aria.checked.should.equal(false); + assert.isUndefined(aria.busy); + assert.isUndefined(aria.controls); + assert.isTrue(delete aria.label); + + let ownerAria = htmx.live.q('#form').q('closest [aria-busy]').aria; + ownerAria.busy.should.equal(false); + ownerAria.busy = true; + aria.checked = true; + aria.busy = false; + + playground().querySelector('form').getAttribute('aria-checked').should.equal('true'); + playground().querySelector('form').getAttribute('aria-busy').should.equal('false'); + playground().querySelector('section').getAttribute('aria-busy').should.equal('true'); + + aria.checked = null; + delete ownerAria.busy; + playground().querySelector('form').hasAttribute('aria-checked').should.equal(false); + playground().querySelector('section').hasAttribute('aria-busy').should.equal(false); + }); + + it('returns every boolean-like ARIA attribute as a boolean', function() { + playground().innerHTML = '
'; + let values = { + atomic: true, + busy: false, + checked: true, + current: false, + disabled: true, + expanded: false, + grabbed: true, + hasPopup: false, + hidden: true, + invalid: false, + modal: true, + multiline: false, + multiselectable: true, + pressed: false, + readonly: true, + required: false, + selected: true + }; + let state = playground().querySelector('#booleans'); + for (let [name, value] of Object.entries(values)) { + state.setAttribute('aria-' + name.toLowerCase(), String(value)); + } + let aria = htmx.live.q(state).aria; + for (let [name, value] of Object.entries(values)) { + aria[name].should.equal(value); + } + }); + + it('returns every numeric ARIA attribute as a number', function() { + playground().innerHTML = '
'; + let values = { + colCount: 3, + colIndex: 2, + colSpan: 1, + level: 4, + posInSet: 5, + rowCount: 6, + rowIndex: 7, + rowSpan: 2, + setSize: 8, + valueMax: 100, + valueMin: 0, + valueNow: 51.5 + }; + let state = playground().querySelector('#state'); + for (let [name, value] of Object.entries(values)) { + let attributeValue = name === 'valueNow' ? ' 51.5 ' : String(value); + state.setAttribute('aria-' + name.toLowerCase(), attributeValue); + } + let aria = htmx.live.q(state).aria; + for (let [name, value] of Object.entries(values)) { + aria[name].should.equal(value); + } + }); + + it('preserves missing and invalid numeric ARIA values', function() { + playground().innerHTML = ` +
+
+ `; + let aria = htmx.live.q('#invalid-numbers').aria; + aria.colSpan.should.equal('1.5'); + aria.level.should.equal('many'); + aria.valueMax.should.equal(''); + aria.valueMin.should.equal('Infinity'); + aria.valueNow.should.equal('unknown'); + assert.isUndefined(aria.rowCount); + }); + + it('does not coerce string ARIA attributes that look typed', function() { + playground().innerHTML = ` +
+
+ `; + let aria = htmx.live.q('#strings').aria; + aria.description.should.equal('true'); + aria.label.should.equal('false'); + aria.valueText.should.equal('51'); + aria.activeDescendant.should.equal('item'); + aria.details.should.equal('details'); + aria.errorMessage.should.equal('error'); + }); + + it('preserves non-boolean ARIA tokens', function() { + playground().innerHTML = '
'; + let aria = htmx.live.q('#tokens').aria; + aria.checked.should.equal('mixed'); + aria.current.should.equal('page'); + aria.invalid.should.equal('spelling'); + }); + + it('returns ARIA list attributes as arrays and joins array writes', function() { + playground().innerHTML = '
'; + let values = { + controls: ['menu', 'help'], + describedBy: ['hint', 'error'], + dropEffect: ['copy', 'move'], + flowTo: ['next', 'later'], + labelledBy: ['title', 'subtitle'], + owns: ['item-1', 'item-2'], + relevant: ['additions', 'text'] + }; + let state = playground().querySelector('#lists'); + for (let [name, value] of Object.entries(values)) { + state.setAttribute('aria-' + name.toLowerCase(), value.join(' ')); + } + state.setAttribute('aria-controls', ' menu help '); + let aria = htmx.live.q(state).aria; + for (let [name, value] of Object.entries(values)) { + aria[name].should.deep.equal(value); + } + + aria.controls = ['dialog', 'help']; + aria.relevant = []; + aria.owns = 'item-3 item-4'; + state.getAttribute('aria-controls').should.equal('dialog help'); + state.getAttribute('aria-relevant').should.equal(''); + state.getAttribute('aria-owns').should.equal('item-3 item-4'); + aria.relevant.should.deep.equal([]); + aria.owns.should.deep.equal(['item-3', 'item-4']); + }); + + it('writes missing ARIA attributes on this and deletes the closest match', function() { + playground().innerHTML = ` +
+ +
+ `; + htmx.process(playground()); + let button = playground().querySelector('button'); + button.click(); + playground().querySelector('div').getAttribute('aria-valuenow').should.equal('51'); + playground().querySelector('div').hasAttribute('aria-current').should.equal(false); + button.getAttribute('aria-label').should.equal('Save'); + }); + + it('q(this).aria only accesses the current element', function() { + playground().innerHTML = ` +
+ +
+ `; + htmx.process(playground()); + let button = playground().querySelector('button'); + button.click(); + window.__localState.should.deep.equal([undefined, true]); + window.__localAfter.should.equal(false); + button.getAttribute('aria-busy').should.equal('false'); + playground().querySelector('section').getAttribute('aria-busy').should.equal('true'); + delete window.__localState; + delete window.__localAfter; + }); + + it('q(this).aria preserves application element properties after await', async function() { + playground().innerHTML = ` +
+ +
+ `; + htmx.process(playground()); + let button = playground().querySelector('button'); + let applicationState = { owner: 'app' }; + button.aria = applicationState; + button.click(); + await htmx.timeout(10); + window.__sameThis.should.equal(true); + window.__closestId.should.equal('owner'); + button.aria.should.equal(applicationState); + button.getAttribute('aria-busy').should.equal('true'); + delete window.__sameThis; + delete window.__closestId; + }); + // ------------------------------------------------------------------------- // cascading data proxy // ------------------------------------------------------------------------- + it("toggle('data-name', values) cycles explicit values", function() { + playground().innerHTML = ` +
+ +
+ `; + htmx.process(playground()); + let owner = playground().querySelector('div'); + let button = playground().querySelector('button'); + button.click(); + owner.dataset.view.should.equal('list'); + button.click(); + owner.dataset.view.should.equal('grid'); + }); + + it("toggle('data-name', \"a\", \"b\") cycles values passed as separate arguments", function() { + playground().innerHTML = ` +
+ +
+ `; + htmx.process(playground()); + let owner = playground().querySelector('div'); + let button = playground().querySelector('button'); + button.click(); + owner.dataset.view.should.equal('list'); + button.click(); + owner.dataset.view.should.equal('grid'); + }); + + it("toggle('data-name') toggles attribute presence", function() { + let button = createProcessedHTML(` + + `); + button.click(); + button.hasAttribute('data-active').should.equal(false); + button.click(); + button.dataset.active.should.equal(''); + }); + + it('q(this).data.active = !q(this).data.active flips a typed boolean', function() { + let button = createProcessedHTML(` + + `); + button.click(); + button.dataset.active.should.equal('true'); + button.click(); + button.dataset.active.should.equal('false'); + }); + + it('data.active = undefined removes the attribute', function() { + let button = createProcessedHTML(` + + `); + button.click(); + button.hasAttribute('data-active').should.equal(false); + }); + + it("take('data-name') moves sibling state", function() { + playground().innerHTML = ` +
+ + +
+ `; + htmx.process(playground()); + let buttons = playground().querySelectorAll('button'); + buttons[1].click(); + buttons[0].hasAttribute('data-active').should.equal(false); + buttons[1].hasAttribute('data-active').should.equal(true); + }); + + it('reads valid JSON values and preserves other data attribute text', function() { + playground().innerHTML = '
'; + let state = playground().querySelector('#state'); + let data = htmx.live.q(state).data; + let values = [ + { label: 'empty string', attribute: '', value: '' }, + { label: 'true', attribute: 'true', value: true }, + { label: 'false', attribute: 'false', value: false }, + { label: 'null', attribute: 'null', value: null }, + { label: 'integer', attribute: '42', value: 42 }, + { label: 'float', attribute: '3.14', value: 3.14 }, + { label: 'negative', attribute: '-0.5', value: -0.5 }, + { label: 'exponent', attribute: '1e3', value: 1000 }, + { label: 'whitespace', attribute: ' 42 ', value: 42 }, + { label: 'object', attribute: '{"count":1}', value: { count: 1 } }, + { label: 'array', attribute: '["one"]', value: ['one'] }, + { label: 'JSON string', attribute: '"hello"', value: 'hello' }, + { label: 'plain string', attribute: 'hello', value: 'hello' }, + { label: 'leading zero', attribute: '01', value: '01' }, + { label: 'leading decimal point', attribute: '.5', value: '.5' }, + { label: 'NaN text', attribute: 'NaN', value: 'NaN' }, + { label: 'Infinity text', attribute: 'Infinity', value: 'Infinity' } + ]; + + assert.isUndefined(data.value); + for (let { label, attribute, value } of values) { + state.setAttribute('data-value', attribute); + state.dataset.value.should.equal(attribute, label + ' raw value'); + assert.deepEqual(data.value, value, label + ' normalized value'); + } + }); + + it('serializes assigned values before reading them back', function() { + playground().innerHTML = '
'; + let state = playground().querySelector('#state'); + let data = htmx.live.q(state).data; + let values = [ + { label: 'empty string', input: '', attribute: '', value: '' }, + { label: 'plain string', input: 'hello', attribute: 'hello', value: 'hello' }, + { label: 'true string', input: 'true', attribute: 'true', value: true }, + { label: 'true', input: true, attribute: 'true', value: true }, + { label: 'false string', input: 'false', attribute: 'false', value: false }, + { label: 'false', input: false, attribute: 'false', value: false }, + { label: 'number string', input: '42', attribute: '42', value: 42 }, + { label: 'number', input: 42, attribute: '42', value: 42 }, + { label: 'float', input: 3.14, attribute: '3.14', value: 3.14 }, + { label: 'negative', input: -0.5, attribute: '-0.5', value: -0.5 }, + { label: 'null string', input: 'null', attribute: 'null', value: null }, + { label: 'null', input: null, attribute: 'null', value: null }, + { label: 'object string', input: '{"count":1}', attribute: '{"count":1}', value: { count: 1 } }, + { label: 'object', input: { count: 1 }, attribute: '{"count":1}', value: { count: 1 } }, + { label: 'array string', input: '["one"]', attribute: '["one"]', value: ['one'] }, + { label: 'array', input: ['one'], attribute: '["one"]', value: ['one'] }, + { label: 'JSON string', input: '"hello"', attribute: '"hello"', value: 'hello' } + ]; + + for (let { label, input, attribute, value } of values) { + data.value = input; + state.dataset.value.should.equal(attribute, label + ' stored value'); + assert.deepEqual(data.value, value, label + ' normalized value'); + } + }); + + it('q().data only accesses the selected element', function() { + playground().innerHTML = ` +
+
+
+ `; + let data = htmx.live.q('#form').data; + data.ready.should.equal(false); + assert.isUndefined(data.count); + ({ ...data }).should.deep.equal({ ready: false }); + + let ownerData = htmx.live.q('#form').q('closest [data-count]').data; + ownerData.count.should.equal(1); + ownerData.count = 2; + data.ready = true; + data.count = 3; + + playground().querySelector('form').dataset.ready.should.equal('true'); + playground().querySelector('form').dataset.count.should.equal('3'); + playground().querySelector('section').dataset.count.should.equal('2'); + + delete data.ready; + delete ownerData.count; + playground().querySelector('form').hasAttribute('data-ready').should.equal(false); + playground().querySelector('section').hasAttribute('data-count').should.equal(false); + }); + + it('q(this).data only accesses the current element after await', async function() { + playground().innerHTML = ` +
+ +
+ `; + htmx.process(playground()); + let button = playground().querySelector('button'); + button.click(); + await htmx.timeout(10); + window.__dataState.should.deep.equal([undefined, 1]); + button.dataset.count.should.equal('2'); + playground().querySelector('section').dataset.count.should.equal('1'); + delete window.__dataState; + }); + + it('q(this).data preserves native element data properties', function() { + playground().innerHTML = ` + + `; + htmx.process(playground()); + let object = playground().querySelector('object'); + object.click(); + object.getAttribute('data').should.equal('/chart.svg'); + object.dataset.ready.should.equal('true'); + }); + + it('delete data.foo removes the closest matching attribute', function() { + playground().innerHTML = ` +
+ +
+ `; + htmx.process(playground()); + playground().querySelector('button').click(); + playground().querySelector('section').hasAttribute('data-state').should.equal(false); + }); + it('data.foo reads this.dataset.foo when present locally', async function() { playground().innerHTML = `
x
+ hx-on:click="this.dataset.v = closest.data.foo">x `; htmx.process(playground()); let elt = playground().querySelector('#me'); @@ -1374,7 +2221,7 @@ describe('hx-live extension', function () { playground().innerHTML = `
- x + x
`; @@ -1386,7 +2233,7 @@ describe('hx-live extension', function () { it('data.foo returns undefined when no ancestor has it', async function() { playground().innerHTML = ` -
x
+
x
`; htmx.process(playground()); let elt = playground().querySelector('#me'); @@ -1398,7 +2245,7 @@ describe('hx-live extension', function () { playground().innerHTML = `
- x + x
`; @@ -1412,7 +2259,7 @@ describe('hx-live extension', function () { playground().innerHTML = `
- +
`; @@ -1427,7 +2274,7 @@ describe('hx-live extension', function () { it('data.foo = "x" writes to this when no ancestor has data-foo', async function() { playground().innerHTML = ` - + `; htmx.process(playground()); let btn = playground().querySelector('#me'); @@ -1438,7 +2285,7 @@ describe('hx-live extension', function () { it('data.foo++ works (auto-coerces to number)', async function() { playground().innerHTML = `
- +
`; htmx.process(playground()); @@ -1451,7 +2298,7 @@ describe('hx-live extension', function () { it('data proxy: boolean round-trips through JSON', async function() { playground().innerHTML = `
- +
`; htmx.process(playground()); @@ -1466,7 +2313,7 @@ describe('hx-live extension', function () { it('data proxy: number round-trips through JSON', async function() { playground().innerHTML = `
- +
`; htmx.process(playground()); @@ -1482,8 +2329,8 @@ describe('hx-live extension', function () { it('data proxy: object round-trips through JSON', async function() { playground().innerHTML = `
- - read + + read
`; htmx.process(playground()); @@ -1501,8 +2348,8 @@ describe('hx-live extension', function () { it('data proxy: array round-trips through JSON', async function() { playground().innerHTML = `
- - count + + count
`; htmx.process(playground()); @@ -1522,7 +2369,7 @@ describe('hx-live extension', function () { it('data proxy: plain string stays as string', async function() { playground().innerHTML = `
- x + x
`; htmx.process(playground()); @@ -1534,7 +2381,7 @@ describe('hx-live extension', function () { it('data proxy: null round-trips through JSON', async function() { playground().innerHTML = `
- x + x
`; htmx.process(playground()); @@ -1546,7 +2393,7 @@ describe('hx-live extension', function () { it('with (data) { foo++ } increments cascading value', async function() { playground().innerHTML = `
- +
`; htmx.process(playground()); @@ -1560,7 +2407,7 @@ describe('hx-live extension', function () { playground().innerHTML = `
@@ -1575,7 +2422,7 @@ describe('hx-live extension', function () { it('data.kebabKey camelCase translation works', async function() { playground().innerHTML = `
- x + x
`; htmx.process(playground()); @@ -1588,7 +2435,7 @@ describe('hx-live extension', function () { playground().innerHTML = `
- +
`; @@ -1606,7 +2453,7 @@ describe('hx-live extension', function () {
@@ -1625,9 +2472,9 @@ describe('hx-live extension', function () {
@@ -1644,7 +2491,7 @@ describe('hx-live extension', function () { playground().innerHTML = `
@@ -1660,7 +2507,7 @@ describe('hx-live extension', function () { it('data is reactive in :attr expressions (re-runs on ancestor data change)', async function() { playground().innerHTML = `
-
+
`; htmx.process(playground()); @@ -1682,12 +2529,12 @@ describe('hx-live extension', function () {
closest.data.message = message; closest.data.level = level; await timeout(3000); - data.message = ''" - :text="data.message" - :.success="data.level === 'success'" - :.error="data.level === 'error'">
+ closest.data.message = ''" + :text="closest.data.message" + :.success="closest.data.level === 'success'" + :.error="closest.data.level === 'error'"> `; htmx.process(playground()); let source = playground().querySelector('#source'); @@ -1761,7 +2608,7 @@ describe('hx-live extension', function () { btn.hasAttribute('disabled').should.equal(true); }); - it(':aria-expanded writes "true"/"false", never removes', async function() { + it(':aria-expanded writes boolean strings', async function() { playground().innerHTML = ` @@ -1941,7 +2788,7 @@ describe('hx-live extension', function () { it(':style replaces an old shorthand with a new longhand', async function() { playground().innerHTML = ` -
`; @@ -2246,4 +3093,47 @@ describe('hx-live extension', function () { }); + // ------------------------------------------------------------------------- + // Open concerns for the sigil design. Each test asserts the behavior we + // want, not the behavior we have today. A skipped test here is a known + // gap, not a regression. Never assert against a function value directly: + // the test runner cannot serialize a function in a failure message and the + // session hangs. Compare identity as a boolean instead. + // ------------------------------------------------------------------------- + + describe('open concerns', function() { + + it.skip('attr() updater receives the typed ARIA value, not a boolean', function() { + playground().innerHTML = ''; + let seen; + htmx.live.attr('#n a', 'aria-current', c => { seen = c; return c; }); + seen.should.equal('page'); + }); + + it.skip('setting value does not change what form.reset() restores', function() { + playground().innerHTML = '
'; + let form = playground().querySelector('#f'); + let input = playground().querySelector('#i'); + htmx.live.attr('#i', 'value', 'edited'); + input.value.should.equal('edited'); + form.reset(); + input.value.should.equal('original'); + }); + + it.skip('setting checked does not make an unchecked box match :default', function() { + playground().innerHTML = ''; + let box = playground().querySelector('#c'); + htmx.live.attr('#c', 'checked', true); + box.checked.should.equal(true); + box.matches(':default').should.equal(false); + }); + + it.skip('data-* round trip preserves a trailing zero decimal', function() { + playground().innerHTML = '
'; + let el = playground().querySelector('#p'); + htmx.live.q('#p').data.price = p => p; + el.dataset.price.should.equal('19.90'); + }); + }); + }); From cbc8400ad0fbdcd75b1ae75d37758e28b395ff5d Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Tue, 11 Aug 2026 17:48:31 +0300 Subject: [PATCH 02/39] Document hx-live state access --- www/src/content/extensions/06-hx-live.md | 408 ++++++++++++++++++++--- 1 file changed, 367 insertions(+), 41 deletions(-) diff --git a/www/src/content/extensions/06-hx-live.md b/www/src/content/extensions/06-hx-live.md index ee4839813..82e2779bf 100644 --- a/www/src/content/extensions/06-hx-live.md +++ b/www/src/content/extensions/06-hx-live.md @@ -22,6 +22,69 @@ The paragraph updates as you type. ``` +## Core state access + +The core API uses `q()` and its state namespaces. + +```js +q('.item').data.open = true +q('.item').class.selected = true +q('.item').aria.busy = false +q('.item').attr.disabled = true +``` + +`q()` reads from the first match and writes to every match. Its `data`, `class`, +`aria`, and `attr` aliases are local and share the same typed state views: + +```js +q('.item').data === q('.item').attr.data +q('.item').class === q('.item').attr.class +q('.item').aria === q('.item').attr.aria +``` + +Use `.closest` for explicit owner lookup: + +```js +q('.item').closest.data.open = true +q('.item').closest.aria.busy = true +q('.item').closest.attr.role = 'tab' +q('.item').closest.class.selected = true +``` + +Closest reads use the first selected element. Closest writes resolve one owner +per selected element, deduplicate shared owners, and fall back to the selected +element when no owner exists. Deletes remove an owner and otherwise do nothing. + +Bare `data` in an expression uses the nearest data owner. Reads return +`undefined` when no owner exists. Writes create local state in that case. + +## Idiomatic hx-live + +Keep local UI state in the DOM, close to the elements that use it: + +```html + + + +``` + +Use these principles: + +1. **Start with the browser.** Prefer native HTML behavior, native DOM properties, and CSS before adding hx-live. +2. **Choose one state owner.** Store each value in one native property, ARIA attribute, `data-*` attribute, or form control. Derive everything else from it. +3. **Use the narrowest shared scope.** Put shared `data-*` state on the nearest common ancestor, then reach it with `data.*` or `.closest.data.*`. +4. **Read state directly.** Prefer native properties, `aria.*`, and `data.*` over selectors and raw attribute access. Use `q()` when the source is outside the current scope. +5. **Bind derived state.** Use `:` for values that follow other DOM state. Use [`hx-on`](/reference/attributes/hx-on) for user actions. +6. **Let CSS handle presentation.** Style native states and semantic attributes instead of maintaining parallel presentation classes. +7. **Use `hx-live` last.** Reserve the imperative form for multi-step work, asynchronous work, and side effects that a binding cannot express. + +Keep every expression safe to run again. DOM changes, input events, and htmx swaps can all recompute live expressions. + ## Attributes ### `:` @@ -61,7 +124,7 @@ Bind a single class to an expression. Truthy adds it, falsy removes it. ```html -

Negative balance

+

Negative balance

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

+

``` Numbers and other non-strings are stringified. @@ -158,16 +221,16 @@ An escape hatch. Use it when no single `:` fits, or for multi-step logic a The helpers work inside `hx-live` expressions, inside [`hx-on`](/reference/attributes/hx-on) event handlers, and from regular JavaScript via `htmx.live.*`. ```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 ``` @@ -224,43 +287,70 @@ For plain descendant queries, CSS is shorter: `q('.card .title')` and `q('.card' The helpers below also work as methods on the proxy, applying across all matched elements: ```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', '
  • new
  • ') // before / after / start / end ``` -### `attr(name, value?)` +### `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" +attr.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. + +`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`) read as numbers. -Get or set an attribute, class, or property on this element. Pass one argument to read, two to write. +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 the typed aliases. Use native +DOM methods or `htmx.live.attr()` when you need exact raw attribute text. + ### `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. +```js +toggle('aria-expanded') +toggle('data-view', 'grid', 'list') +``` ### `take(name, scope?)` @@ -272,9 +362,204 @@ take('aria-current', 'nav a') // become the current nav item take('.active') // implicit scope: parent element's subtree ``` +```js +take('aria-selected') +take('.active') +``` + +### `class` + +Read and write class membership on this element: + +```html + +``` + +Use bracket notation for class names that are not JavaScript identifiers: + +```js +q(this).class['is-active'] = true +delete q(this).class.pending +``` + +Set several classes at once with `q(this).class.assign({ ... })`. Truthy values add, falsy values remove, unmentioned classes survive: + +```html + +``` + +Non-object arguments warn and do nothing. + +The native `classList` methods work through `q(this).class`: + +```js +q(this).class.add('a', 'b') // add classes +q(this).class.remove('a', 'b') // remove classes +q(this).class.toggle('x', force?) // toggle, optional force +q(this).class.replace('a', 'b') // replace one class with another +q(this).class.contains('x') // membership +q(this).class.assign({...}) // group add/remove by truthiness +'x' in q(this).class // membership +``` + +Method names win on read: `q(this).class.toggle` is the method even when a class named `toggle` exists; key writes still create classes. + +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 nearest owner. A write with +no owner 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 sibling owners, then `"true"` on this owner. + +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/). + +**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 +570,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: -Values are automatically JSON-serialized on write and parsed on read. Booleans, numbers, arrays, and objects round-trip transparently: +```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 +``` + +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 +594,30 @@ 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: +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 +631,23 @@ 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 +``` Because `:` works on `data-*`, you can also store derived values in the DOM: @@ -455,7 +779,7 @@ For a single inline section, native [`
    `](https://developer.mozilla.org/
    - + ``` **Toggle button.** @@ -472,13 +796,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.** @@ -498,7 +822,7 @@ For a single inline section, native [`
    `](https://developer.mozilla.org/ ```html Home -
    +
    ``` ## Advanced Examples @@ -610,12 +934,12 @@ When an `hx-live` element is removed, its expression drops out on the next sched ``` -**Anything else.** Stringify the value. `null`, `undefined`, or `false` remove the attribute. +**Anything else.** Stringify the value. `null` or `undefined` remove the attribute. ```html - + ``` @@ -628,6 +952,7 @@ htmx.live.q('.row') htmx.live.$('.row') htmx.live.attr('.row', 'hidden', true) htmx.live.take('.tab.active', '.active', '.tab') +htmx.live.toggle('.tab', 'data-view', 'grid', 'list') ``` `htmx.live.refresh()` forces a recompute. Use it when an expression reads from a source the observer cannot see (a JS variable, a getter, an external store) and you've just mutated it. @@ -726,6 +1051,7 @@ Defaults to `false`. ## Notes +- Bare `data.*` uses the nearest owner. `q(...).data`, `q(...).aria`, `q(...).class`, and `q(...).attr` are local; use `.closest` for explicit owner lookup. - Expressions run on any DOM mutation. There is no per-variable tracking. The microtask coalescing keeps this cheap, but expensive expressions should `debounce` or guard themselves. - The DOM is the source of truth. To share state between expressions, use ARIA attributes, `data-*` attributes (the `data` proxy makes this ergonomic), or hidden inputs. - When using morph swap styles (`innerMorph` / `outerMorph`), server responses will overwrite `data-*` attributes by default. To preserve client-side state during morphs, add a prefix to `morphIgnore` — e.g. `morphIgnore:["data-"]` will protect all `data-*` attributes from being overwritten. Non-morph swaps (`innerHTML`, `outerHTML`) replace the DOM entirely, so state should live on an ancestor element that isn't swapped. From bcff682ed3c50611a6f43b855283d10e38db36cb Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Tue, 11 Aug 2026 18:49:53 +0300 Subject: [PATCH 03/39] Normalize live HTML attribute names --- src/ext/hx-live.js | 39 ++++++++++++++++++++---------------- test/tests/ext/hx-live.js | 42 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 17 deletions(-) diff --git a/src/ext/hx-live.js b/src/ext/hx-live.js index 1c3b6beb0..f159041bd 100644 --- a/src/ext/hx-live.js +++ b/src/ext/hx-live.js @@ -84,6 +84,10 @@ 'size','span','start','rows','cols','width','height' ]); + function normalizeAttrName(elt, name) { + return elt instanceof HTMLElement ? name.toLowerCase() : name; + } + /** * Get or set an attribute or property-backed value on one or more elements. * @@ -102,11 +106,10 @@ * attr('data-x', null) // remove attribute */ function applyAttr(elts, name, ...rest) { - let isAria = name.startsWith('aria-'); - if (rest.length === 0) { let e = elts[0]; if (!e) return undefined; + name = normalizeAttrName(e, name); if (name === 'value' && NUMERIC_INPUT_TYPES.has(e.type)) { return e.value === '' ? null : e.valueAsNumber; } @@ -119,22 +122,24 @@ let value = rest[0]; for (let e of elts) { + let attrName = normalizeAttrName(e, name); + let isAria = attrName.startsWith('aria-'); if (isAria) { - if (value == null) e.removeAttribute(name); - else e.setAttribute(name, String(value)); - } else if (PROPERTY_BINDING_ATTRS.has(name)) { - applyPropertyBinding(e, name, value); - } 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)); + if (value == null) e.removeAttribute(attrName); + else e.setAttribute(attrName, String(value)); + } else if (PROPERTY_BINDING_ATTRS.has(attrName)) { + applyPropertyBinding(e, attrName, value); + } else if (BOOLEAN_ATTRS.has(attrName)) { + if (value) e.setAttribute(attrName, ''); + else e.removeAttribute(attrName); + } else if (STRINGY_BOOLEAN_ATTRS.has(attrName)) { + if (value === null || value === undefined) e.removeAttribute(attrName); + else if (value === true) e.setAttribute(attrName, 'true'); + else if (value === false) e.setAttribute(attrName, 'false'); + else e.setAttribute(attrName, String(value)); } else { - if (value === null || value === undefined) e.removeAttribute(name); - else e.setAttribute(name, value === true ? '' : String(value)); + if (value === null || value === undefined) e.removeAttribute(attrName); + else e.setAttribute(attrName, value === true ? '' : String(value)); } } } @@ -152,7 +157,7 @@ function makeAttrProxy(elts, cascades, scope) { let findOwner = (elt, name) => cascades - ? elt.closest('[' + CSS.escape(name) + ']') + ? elt.closest('[' + CSS.escape(normalizeAttrName(elt, name)) + ']') : elt; return new Proxy({}, { get: (_, name) => { diff --git a/test/tests/ext/hx-live.js b/test/tests/ext/hx-live.js index 9d7816ea2..7e7f80036 100644 --- a/test/tests/ext/hx-live.js +++ b/test/tests/ext/hx-live.js @@ -1484,6 +1484,48 @@ describe('hx-live extension', function () { playground().querySelector('.x').hasAttribute('role').should.equal(false); }); + it('q().attr normalizes DOM-style names for HTML attributes', function() { + playground().innerHTML = ''; + let attr = htmx.live.q('#input').attr; + + attr.readOnly.should.equal(true); + attr.tabIndex.should.equal(2); + attr.maxLength.should.equal(10); + + attr.readOnly = false; + attr.tabIndex = 3; + attr.maxLength = 20; + + let input = playground().querySelector('#input'); + input.hasAttribute('readonly').should.equal(false); + attr.tabIndex.should.equal(3); + attr.maxLength.should.equal(20); + }); + + it('q().closest.attr normalizes DOM-style names before resolving HTML owners', function() { + playground().innerHTML = '
    '; + let attr = htmx.live.q('#input').closest.attr; + + attr.readOnly.should.equal(true); + attr.tabIndex.should.equal(2); + attr.readOnly = false; + attr.tabIndex = 3; + + let fieldset = playground().querySelector('fieldset'); + fieldset.hasAttribute('readonly').should.equal(false); + fieldset.getAttribute('tabindex').should.equal('3'); + }); + + it('q().attr preserves case-sensitive SVG attribute names', function() { + playground().innerHTML = ''; + let attr = htmx.live.q('#svg').attr; + + attr.viewBox.should.equal('0 0 10 10'); + attr.viewBox = '0 0 20 20'; + + playground().querySelector('#svg').getAttribute('viewBox').should.equal('0 0 20 20'); + }); + it('q().attr.data and q().data share the local data view', function() { playground().innerHTML = `
    From 3357fe19c593cc4caf409475fb3a10d7e2fce6ed Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Tue, 11 Aug 2026 20:23:40 +0300 Subject: [PATCH 04/39] Remove speculative hx-live tests --- test/tests/ext/hx-live.js | 43 --------------------------------------- 1 file changed, 43 deletions(-) diff --git a/test/tests/ext/hx-live.js b/test/tests/ext/hx-live.js index 7e7f80036..b89b89c04 100644 --- a/test/tests/ext/hx-live.js +++ b/test/tests/ext/hx-live.js @@ -3135,47 +3135,4 @@ describe('hx-live extension', function () { }); - // ------------------------------------------------------------------------- - // Open concerns for the sigil design. Each test asserts the behavior we - // want, not the behavior we have today. A skipped test here is a known - // gap, not a regression. Never assert against a function value directly: - // the test runner cannot serialize a function in a failure message and the - // session hangs. Compare identity as a boolean instead. - // ------------------------------------------------------------------------- - - describe('open concerns', function() { - - it.skip('attr() updater receives the typed ARIA value, not a boolean', function() { - playground().innerHTML = '
    '; - let seen; - htmx.live.attr('#n a', 'aria-current', c => { seen = c; return c; }); - seen.should.equal('page'); - }); - - it.skip('setting value does not change what form.reset() restores', function() { - playground().innerHTML = '
    '; - let form = playground().querySelector('#f'); - let input = playground().querySelector('#i'); - htmx.live.attr('#i', 'value', 'edited'); - input.value.should.equal('edited'); - form.reset(); - input.value.should.equal('original'); - }); - - it.skip('setting checked does not make an unchecked box match :default', function() { - playground().innerHTML = ''; - let box = playground().querySelector('#c'); - htmx.live.attr('#c', 'checked', true); - box.checked.should.equal(true); - box.matches(':default').should.equal(false); - }); - - it.skip('data-* round trip preserves a trailing zero decimal', function() { - playground().innerHTML = '
    '; - let el = playground().querySelector('#p'); - htmx.live.q('#p').data.price = p => p; - el.dataset.price.should.equal('19.90'); - }); - }); - }); From 917b23dd63cfd2dc76dfcf6ed566b2277fee16c7 Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Wed, 5 Aug 2026 18:17:35 +0300 Subject: [PATCH 05/39] Add functional assignments to hx-live Assigning a function to data.*, a q() property, or attr() calls it with the current value and stores the result. A property that already holds a function keeps the literal value. Async functions throw. # Conflicts: # src/ext/hx-live.js # www/src/content/extensions/06-hx-live.md --- src/ext/hx-live.js | 33 ++++-- test/tests/ext/hx-live.js | 133 +++++++++++++++++++++++ www/src/content/extensions/06-hx-live.md | 25 +++++ 3 files changed, 184 insertions(+), 7 deletions(-) diff --git a/src/ext/hx-live.js b/src/ext/hx-live.js index f159041bd..6feb6003c 100644 --- a/src/ext/hx-live.js +++ b/src/ext/hx-live.js @@ -120,8 +120,8 @@ return raw; } - let value = rest[0]; for (let e of elts) { + let value = maybeCall(rest[0], applyAttr([e], name)); let attrName = normalizeAttrName(e, name); let isAria = attrName.startsWith('aria-'); if (isAria) { @@ -212,6 +212,18 @@ return s.replace(/[A-Z]/g, m => '-' + m.toLowerCase()); } + function maybeCall(value, current) { + if (typeof value !== 'function') return value; + let next = value(current); + if (typeof next?.then === 'function') throw new TypeError('assigned function must return a value, not a promise'); + return next; + } + + function readData(elt, prop) { + let raw = elt.dataset[prop]; + try { return JSON.parse(raw); } catch { return raw; } + } + let booleanAria = new Set([ 'atomic', 'busy', @@ -288,7 +300,9 @@ }, set: (_, name, value) => { if (typeof name !== 'string') return false; - write(name, value); + for (let e of elts) { + writeClass(e, name, maybeCall(value, e.classList.contains(name))); + } return true; }, deleteProperty: (_, name) => { @@ -391,14 +405,15 @@ let kebab = camelToKebab(prop); let ancestor = elts[0] && findOwner(elts[0], kebab); if (!ancestor) return undefined; - let raw = ancestor.dataset[prop]; - try { return JSON.parse(raw); } catch { return raw; } + return readData(ancestor, prop); }, set: (_, prop, val) => { if (typeof prop !== 'string') return false; let kebab = camelToKebab(prop); let name = 'data-' + kebab; - eachTarget(elts, elt => findOwner(elt, kebab), true, elt => writeData(elt, name, val)); + eachTarget(elts, elt => findOwner(elt, kebab), true, elt => { + writeData(elt, name, maybeCall(val, readData(elt, prop))); + }); return true; }, deleteProperty: (_, prop) => { @@ -701,8 +716,12 @@ 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 elt[prop] = maybeCall(value, current); + }); schedule(); return true; } diff --git a/test/tests/ext/hx-live.js b/test/tests/ext/hx-live.js index b89b89c04..0379912a4 100644 --- a/test/tests/ext/hx-live.js +++ b/test/tests/ext/hx-live.js @@ -2314,6 +2314,90 @@ describe('hx-live extension', function () { section.dataset.counter.should.equal('2'); }); + it('functional data assignment updates a selected owner', function() { + playground().innerHTML = ` +
    +
    + +
    +
    + `; + htmx.process(playground()); + playground().querySelector('button').click(); + JSON.parse(playground().querySelector('section').dataset.cart).should.deep.equal([ + { id: '1' }, + { id: '2' } + ]); + }); + + it('functional data assignment can initialize a missing value', function() { + playground().innerHTML = ''; + let button = playground().querySelector('button'); + htmx.live.q(button).data.items = items => [...(items || []), 'one']; + button.dataset.items.should.equal('["one"]'); + }); + + it('functional data assignment runs once and leaves the value when it throws', function() { + playground().innerHTML = '
    '; + let data = htmx.live.q('#state').data; + let calls = 0; + data.count = count => { calls++; return count + 1; }; + calls.should.equal(1); + data.count.should.equal(2); + + let fail = () => { throw new Error('no update'); }; + assert.throws(() => { data.count = fail; }, 'no update'); + data.count.should.equal(2); + + assert.throws(() => { data.count = async count => count + 1; }, 'assigned function must return a value, not a promise'); + data.count.should.equal(2); + }); + + it('functional property assignment via q() updates a DOM property', function() { + playground().innerHTML = ''; + let q = htmx.live.q('#panel'); + q.hidden = hidden => !hidden; + playground().querySelector('#panel').hidden.should.equal(false); + }); + + it('functional property assignment passes the current typed value', function() { + playground().innerHTML = ''; + let q = htmx.live.q('#name'); + let seen; + q.value = v => { seen = v; return v + ' world'; }; + seen.should.equal('hello'); + playground().querySelector('#name').value.should.equal('hello world'); + }); + + it('q() property setter stores an on* handler as a literal function', function() { + playground().innerHTML = ''; + let handler = () => 42; + htmx.live.q('#btn').onclick = handler; + playground().querySelector('#btn').onclick.should.equal(handler); + }); + + it('functional attr assignment via applyAttr updates an attribute', function() { + playground().innerHTML = ''; + htmx.live.attr('#box', 'hidden', hidden => !hidden); + playground().querySelector('#box').hasAttribute('hidden').should.equal(false); + }); + + it('functional attr assignment reads the current typed value', function() { + playground().innerHTML = ''; + let seen; + htmx.live.attr('#box', 'hidden', h => { seen = h; return h; }); + seen.should.equal(true); + }); + + it('functional class assignment toggles correctly', function() { + playground().innerHTML = '
    '; + htmx.live.q('#box').class.on = on => !on; + playground().querySelector('#box').classList.contains('on').should.equal(false); + }); + it('data.foo = "x" writes to this when no ancestor has data-foo', async function() { playground().innerHTML = ` @@ -3135,4 +3219,53 @@ describe('hx-live extension', function () { }); + // ------------------------------------------------------------------------- + // q() property setter. Never assert against a function value directly: the + // test runner cannot serialize a function in a failure message and the + // session hangs. Compare identity as a boolean instead. + // ------------------------------------------------------------------------- + + describe('q() property setter', function() { + + it('q() setter stores a function on a property that already holds one', function() { + playground().innerHTML = '
    '; + let grid = playground().querySelector('#grid'); + grid.rowRenderer = () => 'old'; + let next = () => 'new'; + htmx.live.q('#grid').rowRenderer = next; + (grid.rowRenderer === next).should.equal(true, 'stored the function, not its return value'); + }); + + it('q() setter stores a function on an unset custom property', function() { + playground().innerHTML = '
    '; + let grid = playground().querySelector('#grid'); + let fn = () => 'cell'; + htmx.live.q('#grid').renderCell = fn; + (grid.renderCell === fn).should.equal(true, 'stored the function, not its return value'); + }); + + it('writing .value leaves defaultValue intact for dirty tracking', function() { + playground().innerHTML = ''; + let input = playground().querySelector('#i'); + htmx.live.q('#i').value = 'edited'; + input.value.should.equal('edited'); + input.defaultValue.should.equal('original'); + }); + + it('morph preserves a JS-written value when the server attribute is unchanged', async function() { + playground().innerHTML = '
    '; + htmx.process(playground()); + htmx.live.q('#i').value = 'typed by user'; + await htmx.swap({ + target: '#wrap', + text: '
    ', + swap: 'outerMorph', + sourceElement: playground() + }); + await htmx.timeout(5); + playground().querySelector('#i').value.should.equal('typed by user'); + }); + + }); + }); diff --git a/www/src/content/extensions/06-hx-live.md b/www/src/content/extensions/06-hx-live.md index 82e2779bf..b68e8e265 100644 --- a/www/src/content/extensions/06-hx-live.md +++ b/www/src/content/extensions/06-hx-live.md @@ -649,6 +649,31 @@ 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: ```html From cf3f0b959231e2b434f1989e2208cb1ed3f4fb06 Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Tue, 11 Aug 2026 21:17:29 +0300 Subject: [PATCH 06/39] Clarify HTML attribute name tests --- test/tests/ext/hx-live.js | 69 +++++++++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 17 deletions(-) diff --git a/test/tests/ext/hx-live.js b/test/tests/ext/hx-live.js index 0379912a4..271f4a981 100644 --- a/test/tests/ext/hx-live.js +++ b/test/tests/ext/hx-live.js @@ -1484,31 +1484,61 @@ describe('hx-live extension', function () { playground().querySelector('.x').hasAttribute('role').should.equal(false); }); - it('q().attr normalizes DOM-style names for HTML attributes', function() { + it('q().attr uses lowercase HTML attribute names', function() { + playground().innerHTML = ''; + let attr = htmx.live.q('#input').attr; + + attr.readonly.should.equal(true); + attr.tabindex.should.equal(2); + attr.maxlength.should.equal(10); + + attr.readonly = false; + attr.tabindex = 3; + attr.maxlength = 20; + + let input = playground().querySelector('#input'); + input.hasAttribute('readonly').should.equal(false); + attr.tabindex.should.equal(3); + attr.maxlength.should.equal(20); + }); + + it('q().attr accepts mixed-case HTML attribute names', function() { playground().innerHTML = ''; let attr = htmx.live.q('#input').attr; attr.readOnly.should.equal(true); - attr.tabIndex.should.equal(2); - attr.maxLength.should.equal(10); + attr.TABINDEX.should.equal(2); + attr.MaxLength.should.equal(10); - attr.readOnly = false; - attr.tabIndex = 3; - attr.maxLength = 20; + attr.READONLY = false; + attr.TabIndex = 3; + attr.MAXLENGTH = 20; let input = playground().querySelector('#input'); input.hasAttribute('readonly').should.equal(false); - attr.tabIndex.should.equal(3); - attr.maxLength.should.equal(20); + input.getAttribute('tabindex').should.equal('3'); + input.getAttribute('maxlength').should.equal('20'); + }); + + it('q() keeps native DOM property spelling separate from attr names', function() { + playground().innerHTML = ''; + let input = htmx.live.q('#input'); + + input.readOnly.should.equal(true); + input.tabIndex.should.equal(2); + input.maxLength.should.equal(10); + input.attr.readonly.should.equal(true); + input.attr.tabindex.should.equal(2); + input.attr.maxlength.should.equal(10); }); - it('q().closest.attr normalizes DOM-style names before resolving HTML owners', function() { + it('q().closest.attr normalizes HTML names before resolving owners', function() { playground().innerHTML = '
    '; let attr = htmx.live.q('#input').closest.attr; - attr.readOnly.should.equal(true); - attr.tabIndex.should.equal(2); - attr.readOnly = false; + attr.readonly.should.equal(true); + attr.TABINDEX.should.equal(2); + attr.READONLY = false; attr.tabIndex = 3; let fieldset = playground().querySelector('fieldset'); @@ -1516,14 +1546,19 @@ describe('hx-live extension', function () { fieldset.getAttribute('tabindex').should.equal('3'); }); - it('q().attr preserves case-sensitive SVG attribute names', function() { - playground().innerHTML = ''; - let attr = htmx.live.q('#svg').attr; + it('q().attr preserves distinct SVG attribute casing', function() { + let svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 10 10'); + playground().appendChild(svg); + let attr = htmx.live.q(svg).attr; attr.viewBox.should.equal('0 0 10 10'); - attr.viewBox = '0 0 20 20'; + assert.isNull(attr.viewbox); + + attr.viewbox = '0 0 20 20'; - playground().querySelector('#svg').getAttribute('viewBox').should.equal('0 0 20 20'); + svg.getAttribute('viewBox').should.equal('0 0 10 10'); + svg.getAttribute('viewbox').should.equal('0 0 20 20'); }); it('q().attr.data and q().data share the local data view', function() { From 3d57f7ed63e440ae5971f25ad83149d074604d2c Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Tue, 11 Aug 2026 21:53:06 +0300 Subject: [PATCH 07/39] Remove public live attr helper --- src/ext/hx-live.js | 1 - src/htmx.d.ts | 4 - test/tests/ext/hx-live.js | 114 ++++++++++------------- www/src/content/extensions/06-hx-live.md | 3 +- 4 files changed, 52 insertions(+), 70 deletions(-) diff --git a/src/ext/hx-live.js b/src/ext/hx-live.js index 6feb6003c..2d26319d5 100644 --- a/src/ext/hx-live.js +++ b/src/ext/hx-live.js @@ -876,7 +876,6 @@ refresh: () => schedule(), take: (target, name, scope) => applyTake([...asTargets(target)], name, scope), toggle: (target, name, ...values) => [...asTargets(target)].forEach(e => applyToggle(e, name, ...values)), - attr: (target, name, ...rest) => applyAttr([...asTargets(target)], name, ...rest), forEvent: (...args) => forEvent(null, ...args), nextFrame: () => new Promise(r => requestAnimationFrame(r)) }; diff --git a/src/htmx.d.ts b/src/htmx.d.ts index 1a5601d1a..d4dffdd16 100644 --- a/src/htmx.d.ts +++ b/src/htmx.d.ts @@ -254,10 +254,6 @@ export interface HtmxLive { 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; /** * Resolves on the next matching event, timeout, or interval, whichever fires first. * - `string`: event name on the current element diff --git a/test/tests/ext/hx-live.js b/test/tests/ext/hx-live.js index 271f4a981..cfebb5254 100644 --- a/test/tests/ext/hx-live.js +++ b/test/tests/ext/hx-live.js @@ -1320,16 +1320,16 @@ describe('hx-live extension', function () { }); // ------------------------------------------------------------------------- - // attr() scope helper + // attr proxy // ------------------------------------------------------------------------- - it('attr() getter: boolean attr returns boolean', function() { + it('attr proxy getter: boolean attr returns boolean', function() { playground().innerHTML = ''; - htmx.live.attr('#a', 'disabled').should.equal(true); - htmx.live.attr('#b', 'disabled').should.equal(false); + htmx.live.q('#a').attr.disabled.should.equal(true); + htmx.live.q('#b').attr.disabled.should.equal(false); }); - it('attr() getter: ARIA returns raw strings or null', function() { + it('attr proxy getter: ARIA returns raw strings or null', function() { playground().innerHTML = `
    @@ -1338,131 +1338,119 @@ describe('hx-live extension', function () {
    `; - htmx.live.attr('#a', 'aria-expanded').should.equal('true'); - htmx.live.attr('#b', 'aria-expanded').should.equal('false'); - htmx.live.attr('#c', 'aria-current').should.equal('page'); - htmx.live.attr('#d', 'aria-valuenow').should.equal('50'); - htmx.live.attr('#e', 'aria-controls').should.equal('menu help'); - assert.isNull(htmx.live.attr('#f', 'aria-label')); + htmx.live.q('#a').attr['aria-expanded'].should.equal('true'); + htmx.live.q('#b').attr['aria-expanded'].should.equal('false'); + htmx.live.q('#c').attr['aria-current'].should.equal('page'); + htmx.live.q('#d').attr['aria-valuenow'].should.equal('50'); + htmx.live.q('#e').attr['aria-controls'].should.equal('menu help'); + assert.isNull(htmx.live.q('#f').attr['aria-label']); }); - it('attr() getter: class returns full class string', function() { - playground().innerHTML = '
    '; - htmx.live.attr('#a', 'class').should.equal('foo bar baz'); - }); - - it('attr() getter: regular attr returns string or null', function() { + it('attr proxy 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')); + htmx.live.q('#a').attr['data-x'].should.equal('hello'); + assert.isNull(htmx.live.q('#b').attr['data-x']); }); - it('attr() getter: checked returns live state', function() { + it('attr proxy getter: checked returns live state', function() { playground().innerHTML = ''; let inp = playground().querySelector('#a'); inp.checked = true; - htmx.live.attr('#a', 'checked').should.equal(true); + htmx.live.q('#a').attr.checked.should.equal(true); }); - it('attr() getter: value returns live state', function() { + it('attr proxy getter: value returns live state', function() { playground().innerHTML = ''; let inp = playground().querySelector('#a'); inp.value = 'world'; - htmx.live.attr('#a', 'value').should.equal('world'); + htmx.live.q('#a').attr.value.should.equal('world'); }); - it('attr() setter: boolean attr truthy sets, falsy removes', function() { + it('attr proxy setter: boolean attr truthy sets, falsy removes', function() { playground().innerHTML = ''; - htmx.live.attr('#a', 'disabled', true); + htmx.live.q('#a').attr.disabled = true; playground().querySelector('#a').hasAttribute('disabled').should.equal(true); - htmx.live.attr('#a', 'disabled', false); + htmx.live.q('#a').attr.disabled = false; playground().querySelector('#a').hasAttribute('disabled').should.equal(false); }); - it('attr() setter: ARIA stringifies values and null removes', function() { + it('attr proxy setter: ARIA stringifies values and null removes', function() { playground().innerHTML = '
    '; let div = playground().querySelector('#a'); - htmx.live.attr('#a', 'aria-expanded', true); + htmx.live.q('#a').attr['aria-expanded'] = true; div.getAttribute('aria-expanded').should.equal('true'); - htmx.live.attr('#a', 'aria-expanded', false); + htmx.live.q('#a').attr['aria-expanded'] = false; div.getAttribute('aria-expanded').should.equal('false'); - htmx.live.attr('#a', 'aria-expanded', null); + htmx.live.q('#a').attr['aria-expanded'] = null; div.hasAttribute('aria-expanded').should.equal(false); }); - it('attr() setter: aria-* strings and numbers pass through', function() { + it('attr proxy setter: aria-* strings and numbers pass through', function() { playground().innerHTML = '
    '; // String values (tristate, tokens) pass through unchanged. - htmx.live.attr('#a', 'aria-pressed', 'mixed'); + htmx.live.q('#a').attr['aria-pressed'] = 'mixed'; playground().querySelector('#a').getAttribute('aria-pressed').should.equal('mixed'); - htmx.live.attr('#b', 'aria-current', 'page'); + htmx.live.q('#b').attr['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); + htmx.live.q('#c').attr['aria-valuenow'] = 50; playground().querySelector('#c').getAttribute('aria-valuenow').should.equal('50'); }); - it('attr() treats class as a raw attribute', function() { - playground().innerHTML = '
    '; - let div = playground().querySelector('#a'); - htmx.live.attr('#a', 'class', 'foo bar'); - div.getAttribute('class').should.equal('foo bar'); - }); - - it('attr() setter: checked changes attribute and live state together', function() { + it('attr proxy setter: checked changes attribute and live state together', function() { playground().innerHTML = ''; let inp = playground().querySelector('#a'); inp.checked = false; - htmx.live.attr('#a', 'checked', true); + htmx.live.q('#a').attr.checked = true; inp.hasAttribute('checked').should.equal(true); inp.checked.should.equal(true); }); - it('attr() setter: value changes the attribute and live state together', function() { + it('attr proxy setter: value changes the attribute and live state together', function() { playground().innerHTML = ''; let inp = playground().querySelector('#a'); inp.value = 'live'; - htmx.live.attr('#a', 'value', 'set'); + htmx.live.q('#a').attr.value = 'set'; inp.getAttribute('value').should.equal('set'); inp.value.should.equal('set'); - htmx.live.attr('#a', 'value', null); + htmx.live.q('#a').attr.value = null; inp.hasAttribute('value').should.equal(false); inp.value.should.equal(''); }); - it('attr() setter: regular attr null removes', function() { + it('attr proxy setter: regular attr null removes', function() { playground().innerHTML = '
    '; - htmx.live.attr('#a', 'data-x', null); + htmx.live.q('#a').attr['data-x'] = null; playground().querySelector('#a').hasAttribute('data-x').should.equal(false); }); - it('attr() setter: regular attr stringifies non-string values', function() { + it('attr proxy setter: regular attr stringifies non-string values', function() { playground().innerHTML = '
    '; - htmx.live.attr('#a', 'data-x', 42); + htmx.live.q('#a').attr['data-x'] = 42; playground().querySelector('#a').getAttribute('data-x').should.equal('42'); }); - it('attr() setter: contenteditable false writes "false" string, not removes', function() { + it('attr proxy setter: contenteditable false writes "false" string, not removes', function() { playground().innerHTML = '
    '; - htmx.live.attr('#a', 'contenteditable', false); + htmx.live.q('#a').attr.contenteditable = false; playground().querySelector('#a').getAttribute('contenteditable').should.equal('false'); }); - it('attr() setter: draggable false writes "false" string', function() { + it('attr proxy setter: draggable false writes "false" string', function() { playground().innerHTML = '
    '; - htmx.live.attr('#a', 'draggable', false); + htmx.live.q('#a').attr.draggable = false; playground().querySelector('#a').getAttribute('draggable').should.equal('false'); }); - it('attr() setter: spellcheck false writes "false" string', function() { + it('attr proxy setter: spellcheck false writes "false" string', function() { playground().innerHTML = '
    '; - htmx.live.attr('#a', 'spellcheck', false); + htmx.live.q('#a').attr.spellcheck = false; playground().querySelector('#a').getAttribute('spellcheck').should.equal('false'); }); - it('attr() setter: contenteditable null removes attribute', function() { + it('attr proxy setter: contenteditable null removes attribute', function() { playground().innerHTML = '
    '; - htmx.live.attr('#a', 'contenteditable', null); + htmx.live.q('#a').attr.contenteditable = null; playground().querySelector('#a').hasAttribute('contenteditable').should.equal(false); }); @@ -1799,8 +1787,8 @@ describe('hx-live extension', function () { div.dataset.has.should.equal('true'); }); - it('htmx.live.attr is exposed on public API', function() { - assert.isFunction(htmx.live.attr); + it('htmx.live.attr is not exposed on the public API', function() { + assert.isUndefined(htmx.live.attr); }); // ------------------------------------------------------------------------- @@ -2414,16 +2402,16 @@ describe('hx-live extension', function () { playground().querySelector('#btn').onclick.should.equal(handler); }); - it('functional attr assignment via applyAttr updates an attribute', function() { + it('functional attr assignment updates an attribute', function() { playground().innerHTML = ''; - htmx.live.attr('#box', 'hidden', hidden => !hidden); + htmx.live.q('#box').attr.hidden = hidden => !hidden; playground().querySelector('#box').hasAttribute('hidden').should.equal(false); }); it('functional attr assignment reads the current typed value', function() { playground().innerHTML = ''; let seen; - htmx.live.attr('#box', 'hidden', h => { seen = h; return h; }); + htmx.live.q('#box').attr.hidden = h => { seen = h; return h; }; seen.should.equal(true); }); diff --git a/www/src/content/extensions/06-hx-live.md b/www/src/content/extensions/06-hx-live.md index b68e8e265..46f715f7c 100644 --- a/www/src/content/extensions/06-hx-live.md +++ b/www/src/content/extensions/06-hx-live.md @@ -325,7 +325,7 @@ attr['data-x'] = null ``` Use [`class.*`](#class) and [`aria.*`](#aria) for the typed aliases. Use native -DOM methods or `htmx.live.attr()` when you need exact raw attribute text. +DOM methods when you need exact raw attribute text. ### `toggle(name, values?)` @@ -975,7 +975,6 @@ All [helpers](#helpers) are exposed under `htmx.live.*` for use from regular Jav ```js htmx.live.q('.row') htmx.live.$('.row') -htmx.live.attr('.row', 'hidden', true) htmx.live.take('.tab.active', '.active', '.tab') htmx.live.toggle('.tab', 'data-view', 'grid', 'list') ``` From 2f5024ceea8f095f2f1c3b7025efe82e1451dd4b Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Tue, 11 Aug 2026 22:28:49 +0300 Subject: [PATCH 08/39] Let extensions rewrite JavaScript expressions --- src/htmx.js | 4 +++- test/tests/unit/__executeJavaScript.js | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/htmx.js b/src/htmx.js index a96f88182..3896903cf 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -927,7 +927,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/tests/unit/__executeJavaScript.js b/test/tests/unit/__executeJavaScript.js index 2a8545243..3f810f71a 100644 --- a/test/tests/unit/__executeJavaScript.js +++ b/test/tests/unit/__executeJavaScript.js @@ -13,4 +13,18 @@ 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); + } + }); + }); From 51e1155701e608bb660fa22ff2c72e8ea05b77c4 Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Tue, 11 Aug 2026 22:32:55 +0300 Subject: [PATCH 09/39] Restore bare class state in hx-live --- src/ext/hx-live.js | 64 ++++++++++++++++++++++++ test/tests/ext/hx-live.js | 64 ++++++++++++++++++++++++ www/src/content/extensions/06-hx-live.md | 32 ++++++------ 3 files changed, 144 insertions(+), 16 deletions(-) diff --git a/src/ext/hx-live.js b/src/ext/hx-live.js index 2d26319d5..87856a8a0 100644 --- a/src/ext/hx-live.js +++ b/src/ext/hx-live.js @@ -224,6 +224,65 @@ try { return JSON.parse(raw); } catch { return raw; } } + let JS_TOKEN = new RegExp([ + /\/\/.*/, + /\/\*[\s\S]*?\*\//, + /'(?:[^'\\]|\\.)*'/, + /"(?:[^"\\]|\\.)*"/, + /\/(?:\\.|\[(?:\\.|[^\]])*\]|[^\/\\\n[])+\/[a-z]*/, + /[\w$]+/, + /\S/ + ].map(part => part.source).join('|'), 'g'); + let TEMPLATE_TEXT = /(?:\\.|\$(?!\{)|[^`\\$])*(`|\$\{|$)/y; + let CLASS_ACCESS = /^\s*[.[]/; + let ENDS_VALUE = /^(?:[\w$]+|[)\]}v])$/; + let REGEX_WORDS = new Set(['return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete', 'void', 'throw', 'case', 'do', 'else', 'yield', 'await']); + + function rewriteClass(src) { + if (!/\bclass\s*[.[]/.test(src)) return src; + let out = ''; + let stack = []; + let prev = ''; + let i = 0; + + while (i < src.length) { + if (stack.at(-1) === '`') { + TEMPLATE_TEXT.lastIndex = i; + let [text, stop] = TEMPLATE_TEXT.exec(src); + out += text; + i += text.length; + if (stop === '`') stack.pop(); + else if (stop) stack.push('$'); + prev = stop === '`' ? 'v' : '{'; + continue; + } + + JS_TOKEN.lastIndex = i; + let match = JS_TOKEN.exec(src); + if (!match) return out + src.slice(i); + let token = match[0]; + out += src.slice(i, match.index); + i = match.index + token.length; + + if (token[0] === '/' && (token[1] === '/' || token[1] === '*')) { + out += token; + } else if (token[0] === '/' && token.length > 1 && ENDS_VALUE.test(prev) && !REGEX_WORDS.has(prev)) { + out += '/'; + i = match.index + 1; + prev = '/'; + } else if (token === 'class' && prev !== '.' && CLASS_ACCESS.test(src.slice(i))) { + out += '__hxLiveClass'; + prev = 'v'; + } else { + if (token === '`' || token === '{') stack.push(token); + else if (token === '}') stack.pop(); + out += token; + prev = /^['"/]/.test(token) ? 'v' : token; + } + } + return out; + } + let booleanAria = new Set([ 'atomic', 'busy', @@ -919,6 +978,11 @@ aria: local.aria, closest }); + let code = rewriteClass(detail.code); + if (code !== detail.code) { + detail.code = code; + detail.scope.__hxLiveClass = local.class; + } if (htmx.config.live?.useDollar) detail.scope.$ = detail.scope.q; } }); diff --git a/test/tests/ext/hx-live.js b/test/tests/ext/hx-live.js index cfebb5254..4dc58f04c 100644 --- a/test/tests/ext/hx-live.js +++ b/test/tests/ext/hx-live.js @@ -1052,6 +1052,70 @@ describe('hx-live extension', function () { delete window.__classState; }); + it('bare class reads, writes, deletes, updates, and calls methods', function() { + let button = createProcessedHTML(` + + `); + button.click(); + window.__bareClassState.should.deep.equal([true, false]); + button.classList.contains('pending').should.equal(false); + button.classList.contains('done').should.equal(true); + button.classList.contains('is-active').should.equal(true); + button.classList.contains('remove-me').should.equal(false); + button.classList.contains('spin').should.equal(true); + button.classList.contains('active').should.equal(false); + delete window.__bareClassState; + }); + + it('bare class works in bindings and hx-live', async function() { + let elt = createProcessedHTML(` +
    +
    + `); + await htmx.timeout(5); + elt.hasAttribute('data-selected').should.equal(true); + elt.classList.contains('live').should.equal(true); + }); + + it('bare class rewriting skips other JavaScript syntax', function() { + let button = createProcessedHTML(` + + `); + button.click(); + window.__bareClassSyntax.should.deep.equal([ + true, + 'button', + 'class.active', + true, + 'class.active:true' + ]); + button.classList.contains('qualified').should.equal(true); + delete window.__bareClassSyntax; + }); + it("toggle('.name') toggles membership", function() { let button = createProcessedHTML(` diff --git a/www/src/content/extensions/06-hx-live.md b/www/src/content/extensions/06-hx-live.md index 46f715f7c..d45a3ba59 100644 --- a/www/src/content/extensions/06-hx-live.md +++ b/www/src/content/extensions/06-hx-live.md @@ -304,7 +304,7 @@ attr.hidden = true // add hidden delete attr.hidden // remove hidden attr['aria-expanded'] = false // write aria-expanded="false" attr.contenteditable = false // write contenteditable="false" -attr.class.active = true // typed class state +class.active = true // typed class state attr.value = 'hello' // set the value delete attr['data-x'] // remove data-x ``` @@ -374,8 +374,8 @@ Read and write class membership on this element: ```html @@ -384,31 +384,31 @@ Read and write class membership on this element: Use bracket notation for class names that are not JavaScript identifiers: ```js -q(this).class['is-active'] = true -delete q(this).class.pending +class['is-active'] = true +delete class.pending ``` -Set several classes at once with `q(this).class.assign({ ... })`. Truthy values add, falsy values remove, unmentioned classes survive: +Set several classes at once with `class.assign({ ... })`. Truthy values add, falsy values remove, unmentioned classes survive: ```html - + ``` Non-object arguments warn and do nothing. -The native `classList` methods work through `q(this).class`: +The native `classList` methods work through `class`: ```js -q(this).class.add('a', 'b') // add classes -q(this).class.remove('a', 'b') // remove classes -q(this).class.toggle('x', force?) // toggle, optional force -q(this).class.replace('a', 'b') // replace one class with another -q(this).class.contains('x') // membership -q(this).class.assign({...}) // group add/remove by truthiness -'x' in q(this).class // membership +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.assign({...}) // group add/remove by truthiness +'x' in class // membership ``` -Method names win on read: `q(this).class.toggle` is the method even when a class named `toggle` exists; key writes still create classes. +Method names win on read: `class.toggle` is the method even when a class named `toggle` exists; key writes still create classes. Use `q()` to access another element: From ba90df7476a04d0a8c3531523c7e7291095ed374 Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Tue, 11 Aug 2026 22:33:46 +0300 Subject: [PATCH 10/39] Align hx-live state types --- src/htmx.d.ts | 62 ++++++++++++++++++++++++++++++--------------------- 1 file changed, 36 insertions(+), 26 deletions(-) diff --git a/src/htmx.d.ts b/src/htmx.d.ts index d4dffdd16..6f5ddf48e 100644 --- a/src/htmx.d.ts +++ b/src/htmx.d.ts @@ -166,6 +166,30 @@ export interface HtmxSwapContext { anchor?: string; } +export interface LiveClassProxy { + assign(classes: Record): void; + add(...tokens: string[]): void; + remove(...tokens: string[]): void; + toggle(token: string, force?: boolean): boolean; + replace(oldToken: string, newToken: string): boolean; + contains(token: string): boolean; + [name: string]: any; +} + +export interface LiveAttrProxy { + readonly data: Record; + readonly aria: Record; + readonly class: LiveClassProxy; + [name: string]: any; +} + +export interface LiveStateScope { + readonly attr: LiveAttrProxy; + readonly data: Record; + readonly aria: Record; + readonly class: LiveClassProxy; +} + export interface QProxy { /** Number of matched elements. */ count: number; @@ -176,25 +200,16 @@ export interface QProxy { * 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; + /** Typed attributes on the selected elements. */ + readonly attr: LiveAttrProxy; + /** Typed `data-*` values on the selected elements. */ + readonly data?: Record; + /** Typed `aria-*` values on the selected elements. */ + readonly aria?: Record; + /** Class state and `classList` methods on the selected elements. */ + readonly class: LiveClassProxy; + /** Typed state on the nearest owner of each selected element. */ + readonly closest?: LiveStateScope; /** * 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. @@ -204,7 +219,7 @@ export interface 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; + toggle(name: string, ...values: (string | string[])[]): QProxy; /** * Dispatch a `CustomEvent` from all matched elements. * @param bubbles - Defaults to `true`. @@ -216,11 +231,6 @@ export interface QProxy { * - `'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. */ @@ -253,7 +263,7 @@ 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; + toggle(target: string | Element | NodeList, name: string, ...values: (string | string[])[]): void; /** * Resolves on the next matching event, timeout, or interval, whichever fires first. * - `string`: event name on the current element From 0b9544e8156524cbce3998829312a43e49c4c477 Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Tue, 11 Aug 2026 22:40:41 +0300 Subject: [PATCH 11/39] Keep toggle in hx-live reference --- www/src/content/extensions/06-hx-live.md | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/www/src/content/extensions/06-hx-live.md b/www/src/content/extensions/06-hx-live.md index d45a3ba59..9d0065e21 100644 --- a/www/src/content/extensions/06-hx-live.md +++ b/www/src/content/extensions/06-hx-live.md @@ -692,14 +692,6 @@ Shorthand for `this.style`. ``` -### `classList` - -Shorthand for `this.classList`. - -```html - -``` - ### `matches(selector)` Shorthand for `this.matches(selector)`. @@ -802,7 +794,7 @@ For a single inline section, native [`
    `](https://developer.mozilla.org/ ```html
    - +
    ``` @@ -810,7 +802,7 @@ For a single inline section, native [`
    `](https://developer.mozilla.org/ **Toggle button.** ```html - + ``` ```css From 729b3c4d7e4c7bef8d36347fc2297a7f54f392d3 Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Tue, 11 Aug 2026 22:50:06 +0300 Subject: [PATCH 12/39] Simplify bare class rewriting --- src/ext/hx-live.js | 71 ++++++--------------------------------- test/tests/ext/hx-live.js | 10 +++--- 2 files changed, 16 insertions(+), 65 deletions(-) diff --git a/src/ext/hx-live.js b/src/ext/hx-live.js index 87856a8a0..b76e883fe 100644 --- a/src/ext/hx-live.js +++ b/src/ext/hx-live.js @@ -224,63 +224,18 @@ try { return JSON.parse(raw); } catch { return raw; } } - let JS_TOKEN = new RegExp([ - /\/\/.*/, - /\/\*[\s\S]*?\*\//, - /'(?:[^'\\]|\\.)*'/, - /"(?:[^"\\]|\\.)*"/, - /\/(?:\\.|\[(?:\\.|[^\]])*\]|[^\/\\\n[])+\/[a-z]*/, - /[\w$]+/, - /\S/ - ].map(part => part.source).join('|'), 'g'); - let TEMPLATE_TEXT = /(?:\\.|\$(?!\{)|[^`\\$])*(`|\$\{|$)/y; - let CLASS_ACCESS = /^\s*[.[]/; - let ENDS_VALUE = /^(?:[\w$]+|[)\]}v])$/; - let REGEX_WORDS = new Set(['return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete', 'void', 'throw', 'case', 'do', 'else', 'yield', 'await']); + // Protect quoted text and regex literals, then recurse into template expressions. + let CLASS_TOKEN = /(['"`\/])(?:\\.|(?!\1)[^\\\n])*\1|(? 1 && ENDS_VALUE.test(prev) && !REGEX_WORDS.has(prev)) { - out += '/'; - i = match.index + 1; - prev = '/'; - } else if (token === 'class' && prev !== '.' && CLASS_ACCESS.test(src.slice(i))) { - out += '__hxLiveClass'; - prev = 'v'; - } else { - if (token === '`' || token === '{') stack.push(token); - else if (token === '}') stack.pop(); - out += token; - prev = /^['"/]/.test(token) ? 'v' : token; - } - } - return out; + return src.replace(CLASS_TOKEN, token => { + if (token === 'class') return 'attr.class'; + if (token[0] === '`') return token.replace( + /\$\{((?:[^{}]|\{[^{}]*\})*)\}/g, + (_, code) => '${' + rewriteClass(code) + '}' + ); + return token; + }); } let booleanAria = new Set([ @@ -978,11 +933,7 @@ aria: local.aria, closest }); - let code = rewriteClass(detail.code); - if (code !== detail.code) { - detail.code = code; - detail.scope.__hxLiveClass = local.class; - } + detail.code = rewriteClass(detail.code); if (htmx.config.live?.useDollar) detail.scope.$ = detail.scope.q; } }); diff --git a/test/tests/ext/hx-live.js b/test/tests/ext/hx-live.js index 4dc58f04c..90d756baa 100644 --- a/test/tests/ext/hx-live.js +++ b/test/tests/ext/hx-live.js @@ -1091,9 +1091,9 @@ describe('hx-live extension', function () { +``` + +```text +Ada → Hello, Ada → Continue enabled +empty → Hello, → Continue disabled ``` -`q()` reads from the first match and writes to every match. Its `data`, `class`, -`aria`, and `attr` aliases are local and share the same typed state views: +See [Attributes](#attributes) for every binding target. + +### Find Elements + +Use `q()` to reach DOM state outside the current element: ```js -q('.item').data === q('.item').attr.data -q('.item').class === q('.item').attr.class -q('.item').aria === q('.item').attr.aria +q('previous input') // nearby +q('#name') // by ID +q('.item') // every match +q('closest .field') // nearest matching ancestor ``` -Use `.closest` for explicit owner lookup: +Reads use the first match. Writes update every match: ```js -q('.item').closest.data.open = true -q('.item').closest.aria.busy = true -q('.item').closest.attr.role = 'tab' -q('.item').closest.class.selected = true +q('.item').aria.busy = true ``` -Closest reads use the first selected element. Closest writes resolve one owner -per selected element, deduplicate shared owners, and fall back to the selected -element when no owner exists. Deletes remove an owner and otherwise do nothing. - -Bare `data` in an expression uses the nearest data owner. Reads return -`undefined` when no owner exists. Writes create local state in that case. +See [`q()`](#q) for directional selectors, scoped selectors, and chained queries. -## Idiomatic hx-live +### Handle an Event -Keep local UI state in the DOM, close to the elements that use it: +Use [`hx-on`](/reference/attributes/hx-on) to change state after an event: -```html +```html tab="HTML" +``` - +```css tab="CSS" +[aria-pressed="true"] { + background: var(--selected); +} ``` -Use these principles: +### Share State + +Put shared state on the nearest common ancestor: -1. **Start with the browser.** Prefer native HTML behavior, native DOM properties, and CSS before adding hx-live. -2. **Choose one state owner.** Store each value in one native property, ARIA attribute, `data-*` attribute, or form control. Derive everything else from it. -3. **Use the narrowest shared scope.** Put shared `data-*` state on the nearest common ancestor, then reach it with `data.*` or `.closest.data.*`. -4. **Read state directly.** Prefer native properties, `aria.*`, and `data.*` over selectors and raw attribute access. Use `q()` when the source is outside the current scope. -5. **Bind derived state.** Use `:` for values that follow other DOM state. Use [`hx-on`](/reference/attributes/hx-on) for user actions. -6. **Let CSS handle presentation.** Style native states and semantic attributes instead of maintaining parallel presentation classes. -7. **Use `hx-live` last.** Reserve the imperative form for multi-step work, asynchronous work, and side effects that a binding cannot express. +```html +
    + + +
    +``` + +When the next value depends on the current value: + +```js +data.items = items => [...items, next] +``` + +See [`data`](#data) for typed values, owner 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; +} +``` -Keep every expression safe to run again. DOM changes, input events, and htmx swaps can all recompute live expressions. +[`timeout()`](/reference/methods/htmx-timeout) waits before the transition. [`forEvent()`](#foreventargs) waits for the transition or its fallback timeout. ## Attributes @@ -753,7 +813,7 @@ Typical use: wait for a CSS transition to finish, with a safety timeout. ```html @@ -765,9 +825,9 @@ Resolve on the next animation frame. ```html ``` From b007db8f181f1d7a5726bd7779f112012d9cb910 Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Tue, 11 Aug 2026 23:56:12 +0300 Subject: [PATCH 14/39] Simplify hx-live usage wording --- www/src/content/extensions/06-hx-live.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/src/content/extensions/06-hx-live.md b/www/src/content/extensions/06-hx-live.md index b27bd6cfb..387d4eff0 100644 --- a/www/src/content/extensions/06-hx-live.md +++ b/www/src/content/extensions/06-hx-live.md @@ -30,7 +30,7 @@ Can HTML provide the behavior? ### Bind an Attribute -Prefix a binding target with `:`: +Prefix an attribute with `:`: ```html From 4bfc8641d7117c6c147a037c5d22e60ab865678a Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Wed, 12 Aug 2026 00:00:29 +0300 Subject: [PATCH 15/39] Cover bare class parser edge cases --- test/tests/ext/hx-live.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/tests/ext/hx-live.js b/test/tests/ext/hx-live.js index 90d756baa..e469c016f 100644 --- a/test/tests/ext/hx-live.js +++ b/test/tests/ext/hx-live.js @@ -1116,6 +1116,26 @@ describe('hx-live extension', function () { delete window.__bareClassSyntax; }); + it('bare class works between division operators', function() { + let button = createProcessedHTML(` + + `); + button.click(); + window.__bareClassDivision.should.equal(4); + delete window.__bareClassDivision; + }); + + it('bare class works in deeply nested template expressions', function() { + let button = createProcessedHTML(` + + `); + button.click(); + window.__bareClassTemplate.should.equal('value:yes'); + delete window.__bareClassTemplate; + }); + it("toggle('.name') toggles membership", function() { let button = createProcessedHTML(` From f35644feacdefc8ad14e871990ddbe358fc60219 Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Wed, 12 Aug 2026 20:58:19 +0300 Subject: [PATCH 16/39] Prevent overlapping async hx-live effects --- src/ext/hx-live.js | 6 ++++++ test/tests/ext/hx-live.js | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/ext/hx-live.js b/src/ext/hx-live.js index b76e883fe..baa0ebeca 100644 --- a/src/ext/hx-live.js +++ b/src/ext/hx-live.js @@ -793,16 +793,22 @@ let code = elt.getAttribute(bodyAttr) let debounce = getDebounce(elt); let exec; + let isAsync = /\bawait\b/.test(code); + let running = false; let run = async () => { if (!elt.isConnected) { fns.delete(run); return; } + if (isAsync && running) return; + running = isAsync; 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 }); + } finally { + if (isAsync) queueMicrotask(() => running = false); } }; fns.add(run); diff --git a/test/tests/ext/hx-live.js b/test/tests/ext/hx-live.js index e469c016f..02bfff9fd 100644 --- a/test/tests/ext/hx-live.js +++ b/test/tests/ext/hx-live.js @@ -204,6 +204,41 @@ describe('hx-live extension', function () { elt.dataset.v.should.equal('done'); }); + it('does not rerun after an async hx-live body writes to the DOM', async function() { + window.__asyncBodyRuns = 0; + let elt = createProcessedHTML(` + + `); + await htmx.timeout(30); + elt.textContent.should.equal('done'); + let settledRuns = window.__asyncBodyRuns; + await htmx.timeout(20); + window.__asyncBodyRuns.should.equal(settledRuns); + assert.isAtMost(settledRuns, 2); + delete window.__asyncBodyRuns; + }); + + it('does not rerun after an async hx-live body writes before another await', async function() { + window.__multiStepBodyRuns = 0; + playground().innerHTML = ` + + `; + await htmx.timeout(1); + htmx.process(playground()); + let elt = playground().querySelector('output'); + await htmx.timeout(10); + window.__multiStepBodyRuns.should.equal(1); + await htmx.timeout(20); + elt.classList.contains('done').should.equal(true); + delete window.__multiStepBodyRuns; + }); + it('forEvent(event, ms) resolves on event before timeout', async function() { let elt = createProcessedHTML(''); await htmx.timeout(5); From d3307c4a566898437fe92e322500a34b447f5d7b Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Wed, 12 Aug 2026 21:01:01 +0300 Subject: [PATCH 17/39] Name hx-live bindings and effects consistently --- src/ext/hx-live.js | 89 ++++++++++++------------ test/manual/hx-live/index.html | 2 +- test/tests/ext/hx-live.js | 38 +++++----- www/src/content/extensions/06-hx-live.md | 4 +- 4 files changed, 67 insertions(+), 66 deletions(-) diff --git a/src/ext/hx-live.js b/src/ext/hx-live.js index baa0ebeca..b8c61fab7 100644 --- a/src/ext/hx-live.js +++ b/src/ext/hx-live.js @@ -743,7 +743,7 @@ return proxy; } - let liveQuery, bindPrefixes, bodyAttrs; + let liveQuery, bindPrefixes, hxLiveNames; function buildLiveQuery() { let mc = htmx.config.metaCharacter || ':'; @@ -760,11 +760,11 @@ } } 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 hxLive = hxLiveNames.map(n => `@${n}`).join(' or '); + liveQuery = new XPathEvaluator().createExpression(`.//*[@*[${bind}] or ${hxLive}]`); } function extractBindingName(attrName) { @@ -778,51 +778,52 @@ 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 registerEffect(elt, prop) { + let hxLiveName = hxLiveNames.find(name => elt.hasAttribute(name)); + if (!hxLiveName) return; + prop.effectRegistered = true; + ensureActive(); + let code = elt.getAttribute(hxLiveName) + let debounce = getDebounce(elt); + let exec; + let isAsync = /\bawait\b/.test(code); + let running = false; + let run = async () => { + if (!elt.isConnected) { + fns.delete(run); + return; + } + if (isAsync && running) return; + running = isAsync; + 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 }); + } finally { + if (isAsync) queueMicrotask(() => running = false); + } + }; + fns.add(run); + prop.liveRuns = prop.liveRuns || new Set(); + prop.liveRuns.add(run); + run(); } 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 isAsync = /\bawait\b/.test(code); - let running = false; - let run = async () => { - if (!elt.isConnected) { - fns.delete(run); - return; - } - if (isAsync && running) return; - running = isAsync; - 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 }); - } finally { - if (isAsync) queueMicrotask(() => running = false); - } - }; - fns.add(run); - prop.liveRuns = prop.liveRuns || new Set(); - prop.liveRuns.add(run); - run(); - } - } - prop.liveAttrs ||= new Set(); + if (!prop.effectRegistered) registerEffect(elt, prop); + 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); + registerBinding(elt, name, a.value); } } @@ -834,7 +835,7 @@ for (node of nodes) processElement(node); } - function registerSimpleLive(elt, attrName, code) { + function registerBinding(elt, attrName, code) { ensureActive(); let debounce = getDebounce(elt); let isAsync = /\bawait\b/.test(code); 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 02bfff9fd..17ff03303 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,25 +204,25 @@ describe('hx-live extension', function () { elt.dataset.v.should.equal('done'); }); - it('does not rerun after an async hx-live body writes to the DOM', async function() { - window.__asyncBodyRuns = 0; + 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.__asyncBodyRuns; + let settledRuns = window.__asyncEffectRuns; await htmx.timeout(20); - window.__asyncBodyRuns.should.equal(settledRuns); + window.__asyncEffectRuns.should.equal(settledRuns); assert.isAtMost(settledRuns, 2); - delete window.__asyncBodyRuns; + delete window.__asyncEffectRuns; }); - it('does not rerun after an async hx-live body writes before another await', async function() { - window.__multiStepBodyRuns = 0; + it('does not rerun after an async effect writes before another await', async function() { + window.__multiStepEffectRuns = 0; playground().innerHTML = ` `; htmx.process(playground()); await htmx.timeout(20); @@ -3182,14 +3182,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 = `
    @@ -3208,7 +3208,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()); @@ -3225,7 +3225,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()); @@ -3249,7 +3249,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/www/src/content/extensions/06-hx-live.md b/www/src/content/extensions/06-hx-live.md index 387d4eff0..b2d587e3a 100644 --- a/www/src/content/extensions/06-hx-live.md +++ b/www/src/content/extensions/06-hx-live.md @@ -170,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 @@ -894,7 +894,7 @@ 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 From 61d8606fb91c579833bf3e556de6ff584b20ed2c Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Fri, 14 Aug 2026 22:49:04 +0300 Subject: [PATCH 18/39] Refine hx-live DOM state API --- src/ext/hx-live.js | 262 ++-- src/htmx.d.ts | 273 ++-- test/tests/ext/hx-live.js | 1689 +++++----------------- www/src/content/extensions/06-hx-live.md | 60 +- 4 files changed, 686 insertions(+), 1598 deletions(-) diff --git a/src/ext/hx-live.js b/src/ext/hx-live.js index b76e883fe..7c8e5f827 100644 --- a/src/ext/hx-live.js +++ b/src/ext/hx-live.js @@ -77,82 +77,58 @@ 'allowfullscreen','itemscope','nomodule','checked','selected' ]); let PROPERTY_BINDING_ATTRS = new Set(['checked','value','selected']); - let STRINGY_BOOLEAN_ATTRS = new Set(['contenteditable','draggable','spellcheck']); - let NUMERIC_INPUT_TYPES = new Set(['number', 'range']); - let NUMERIC_ATTRS = new Set([ - 'tabindex','colspan','rowspan','maxlength','minlength', - 'size','span','start','rows','cols','width','height' - ]); + let STRING_BOOLEAN_ATTRS = new Set(['contenteditable','draggable','spellcheck','writingsuggestions']); + let NUMERIC_INPUT_TYPES = new Set('number range'.split(' ')); + let NUMERIC_ATTRS = new Set('tabindex colspan rowspan maxlength minlength size span start rows cols width height'.split(' ')); function normalizeAttrName(elt, name) { return elt instanceof HTMLElement ? name.toLowerCase() : name; } - /** - * Get or set an attribute or property-backed value on one or more elements. - * - * @param {Element[]} elts - Target elements. - * @param {string} name - 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('class', 'foo bar') // raw class attribute - * attr('aria-expanded', open) // ARIA: raw string value - * attr('value', 'hello') // set the value attribute - * attr('contenteditable', false) // "false", not removed - * attr('data-x', null) // remove attribute - */ - function applyAttr(elts, name, ...rest) { - if (rest.length === 0) { - let e = elts[0]; - if (!e) return undefined; - name = normalizeAttrName(e, name); - if (name === 'value' && NUMERIC_INPUT_TYPES.has(e.type)) { - return e.value === '' ? null : e.valueAsNumber; - } - if (PROPERTY_BINDING_ATTRS.has(name)) return e[name]; - if (BOOLEAN_ATTRS.has(name)) return e.hasAttribute(name); - let raw = e.getAttribute(name); - if (NUMERIC_ATTRS.has(name) && raw?.trim() && Number.isFinite(Number(raw))) return Number(raw); - return raw; + 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' && NUMERIC_INPUT_TYPES.has(element.type)) { + return element.value === '' ? null : element.valueAsNumber; } - - for (let e of elts) { - let value = maybeCall(rest[0], applyAttr([e], name)); - let attrName = normalizeAttrName(e, name); - let isAria = attrName.startsWith('aria-'); - if (isAria) { - if (value == null) e.removeAttribute(attrName); - else e.setAttribute(attrName, String(value)); - } else if (PROPERTY_BINDING_ATTRS.has(attrName)) { - applyPropertyBinding(e, attrName, value); - } else if (BOOLEAN_ATTRS.has(attrName)) { - if (value) e.setAttribute(attrName, ''); - else e.removeAttribute(attrName); - } else if (STRINGY_BOOLEAN_ATTRS.has(attrName)) { - if (value === null || value === undefined) e.removeAttribute(attrName); - else if (value === true) e.setAttribute(attrName, 'true'); - else if (value === false) e.setAttribute(attrName, 'false'); - else e.setAttribute(attrName, String(value)); - } else { - if (value === null || value === undefined) e.removeAttribute(attrName); - else e.setAttribute(attrName, value === true ? '' : String(value)); - } + if (PROPERTY_BINDING_ATTRS.has(name)) return element[name]; + if (BOOLEAN_ATTRS.has(name)) return element.hasAttribute(name); + let value = element.getAttribute(name); + if (STRING_BOOLEAN_ATTRS.has(name)) try { return JSON.parse(value.toLowerCase()); } catch {} + if (NUMERIC_ATTRS.has(name) && value?.trim() && Number.isFinite(Number(value))) return Number(value); + return 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('assigned function must return a value, not 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)) { + if (value) element.setAttribute(name, ''); + else element.removeAttribute(name); + } else if (value === null || value === undefined) { + element.removeAttribute(name); + } else { + element.setAttribute(name, String(value)); } } function eachTarget(elts, findOwner, fallback, fn) { - let seen = new Set(); + let targets = new Set(); for (let elt of elts) { let target = findOwner(elt) || (fallback ? elt : null); - if (target && !seen.has(target)) { - seen.add(target); - fn(target); - } + if (target) targets.add(target); } + for (let target of targets) fn(target); } function makeAttrProxy(elts, cascades, scope) { @@ -161,19 +137,21 @@ : elt; return new Proxy({}, { get: (_, name) => { - if (name === 'data' || name === 'aria' || name === 'class') return scope[name]; + if (name === 'class') return scope.class; if (typeof name !== 'string') return undefined; let owner = elts[0] && findOwner(elts[0], name); - return owner ? applyAttr([owner], name) : undefined; + return owner ? readAttr(owner, name) : undefined; }, set: (_, name, value) => { if (typeof name !== 'string') return false; - eachTarget(elts, elt => findOwner(elt, name), true, elt => applyAttr([elt], name, value)); + eachTarget(elts, elt => findOwner(elt, name), true, elt => { + writeAttr(elt, name, value); + }); return true; }, deleteProperty: (_, name) => { if (typeof name !== 'string') return false; - eachTarget(elts, elt => findOwner(elt, name), false, elt => applyAttr([elt], name, null)); + eachTarget(elts, elt => findOwner(elt, name), false, elt => writeAttr(elt, name, null)); return true; } }); @@ -212,15 +190,9 @@ return s.replace(/[A-Z]/g, m => '-' + m.toLowerCase()); } - function maybeCall(value, current) { - if (typeof value !== 'function') return value; - let next = value(current); - if (typeof next?.then === 'function') throw new TypeError('assigned function must return a value, not a promise'); - return next; - } - - function readData(elt, prop) { - let raw = elt.dataset[prop]; + function readData(elt, name) { + let raw = elt.getAttribute(name); + if (raw === null) return undefined; try { return JSON.parse(raw); } catch { return raw; } } @@ -238,63 +210,29 @@ }); } - let booleanAria = new Set([ - 'atomic', - 'busy', - 'checked', - 'current', - 'disabled', - 'expanded', - 'grabbed', - 'haspopup', - 'hidden', - 'invalid', - 'modal', - 'multiline', - 'multiselectable', - 'pressed', - 'readonly', - 'required', - 'selected' - ]); - let integerAria = new Set([ - 'colcount', - 'colindex', - 'colspan', - 'level', - 'posinset', - 'rowcount', - 'rowindex', - 'rowspan', - 'setsize' - ]); - let numberAria = new Set([ - 'valuemax', - 'valuemin', - 'valuenow' - ]); - let listAria = new Set([ - 'controls', - 'describedby', - 'dropeffect', - 'flowto', - 'labelledby', - 'owns', - 'relevant' - ]); + 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(element, name) { + return element.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('assigned function must return a value, not a promise'); + } elt.classList.toggle(name, !!value); if (!elt.classList.length) elt.removeAttribute('class'); } - let CLASS_WRITE_METHODS = new Set(['add', 'remove', 'toggle', 'replace']); + let CLASS_WRITE_METHODS = new Set('add remove toggle replace'.split(' ')); function makeClassProxy(elts) { let first = elts[0]; let write = (name, value) => { for (let e of elts) writeClass(e, name, value); }; return new Proxy({}, { get: (_, name) => { - if (typeof name !== 'string' || !first) return undefined; + if (!first) return name === Symbol.iterator ? () => [][Symbol.iterator]() : undefined; if (name === 'assign') { return value => { if (!value || typeof value !== 'object' || Array.isArray(value)) { @@ -304,19 +242,31 @@ for (let e of elts) writeClasses(e, value); }; } - let m = first.classList[name]; - if (typeof m === 'function') { - return elts.length === 1 || !CLASS_WRITE_METHODS.has(name) - ? m.bind(first.classList) - : (...args) => { for (let e of elts) e.classList[name](...args); }; + if (name in first.classList) { + let member = first.classList[name]; + if (typeof member !== 'function') return member; + if (typeof name !== 'string' || elts.length === 1 || !CLASS_WRITE_METHODS.has(name)) { + return member.bind(first.classList); + } + return (...args) => { + let result; + for (let i = 0; i < elts.length; i++) { + let next = elts[i].classList[name](...args); + if (i === 0) result = next; + } + return result; + }; } - return first.classList.contains(name); + if (typeof name !== 'string') return undefined; + return readClass(first, name); }, set: (_, name, value) => { if (typeof name !== 'string') return false; - for (let e of elts) { - writeClass(e, name, maybeCall(value, e.classList.contains(name))); + if (name === 'value') { + for (let elt of elts) elt.classList.value = value; + return true; } + for (let elt of elts) writeClass(elt, name, value); return true; }, deleteProperty: (_, name) => { @@ -324,9 +274,9 @@ write(name, false); return true; }, - has: (_, name) => typeof name === 'string' && !!first && first.classList.contains(name), + has: (_, name) => typeof name === 'string' && !!first && readClass(first, name), ownKeys: () => first ? [...first.classList] : [], - getOwnPropertyDescriptor: (_, name) => first && first.classList.contains(name) + getOwnPropertyDescriptor: (_, name) => first && readClass(first, name) ? { enumerable: true, configurable: true } : undefined }); @@ -338,7 +288,9 @@ get: (_, name) => typeof name === 'string' && !!elts[0] ? !!owner(elts[0], name) : undefined, set: (_, name, value) => { if (typeof name !== 'string') return false; - eachTarget(elts, elt => owner(elt, name), true, elt => writeClass(elt, name, value)); + eachTarget(elts, elt => owner(elt, name), true, elt => { + writeClass(elt, name, value); + }); return true; }, deleteProperty: (_, name) => { @@ -366,6 +318,13 @@ 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 || stringAria.has(key)) return value; + if (listAria.has(key)) return value.trim() ? value.trim().split(/\s+/) : []; + try { return JSON.parse(value); } catch { return value; } + } + function makeAriaProxy(elts, cascades = true) { let findOwner = (elt, name) => cascades ? elt.closest('[' + name + ']') @@ -376,19 +335,15 @@ let key = prop.toLowerCase(); let name = 'aria-' + key; let owner = elts[0] && findOwner(elts[0], name); - let value = owner?.getAttribute(name); - if (booleanAria.has(key) && (value === 'true' || value === 'false')) return value === 'true'; - let number = Number(value); - let validNumber = numberAria.has(key) || (integerAria.has(key) && Number.isInteger(number)); - if (validNumber && value?.trim() && Number.isFinite(number)) return number; - if (listAria.has(key) && value != null) return value.trim() ? value.trim().split(/\s+/) : []; - return value; + return readAria(owner, key); }, set: (_, prop, value) => { if (typeof prop !== 'string') return false; let key = prop.toLowerCase(); let name = 'aria-' + key; - eachTarget(elts, elt => findOwner(elt, name), true, elt => writeAria(elt, key, value)); + eachTarget(elts, elt => findOwner(elt, name), true, elt => { + writeAttr(elt, name, value); + }); return true; }, deleteProperty: (_, prop) => { @@ -417,16 +372,17 @@ get: (_, prop) => { if (typeof prop !== 'string') return undefined; let kebab = camelToKebab(prop); + let name = 'data-' + kebab; let ancestor = elts[0] && findOwner(elts[0], kebab); if (!ancestor) return undefined; - return readData(ancestor, prop); + return readData(ancestor, name); }, set: (_, prop, val) => { if (typeof prop !== 'string') return false; let kebab = camelToKebab(prop); let name = 'data-' + kebab; eachTarget(elts, elt => findOwner(elt, kebab), true, elt => { - writeData(elt, name, maybeCall(val, readData(elt, prop))); + writeAttr(elt, name, val); }); return true; }, @@ -499,7 +455,7 @@ for (let [key, cond] of Object.entries(value)) { for (let c of key.trim().split(/\s+/).filter(Boolean)) { written.push(c); - writeClass(elt, c, cond); + writeClass(elt, c, !!cond); } } } @@ -734,7 +690,13 @@ elts.forEach(elt => { let current = elt[prop]; if (current == null || typeof current === 'function') elt[prop] = value; - else elt[prop] = maybeCall(value, current); + else if (typeof value === 'function') { + let next = value(current); + if (typeof next?.then === 'function') throw new TypeError('assigned function must return a value, not a promise'); + elt[prop] = next; + } else { + elt[prop] = value; + } }); schedule(); return true; @@ -855,6 +817,7 @@ } function writeAttrBinding(elt, attrName, value) { + if (typeof value === 'function') throw new TypeError('binding expression must return a value, not a function'); if (attrName === 'text') { let s = value == null ? '' : String(value); if (elt.textContent !== s) elt.textContent = s; @@ -870,13 +833,8 @@ applyClassBinding(elt, attrName, value); return; } - if (PROPERTY_BINDING_ATTRS.has(attrName)) { - applyPropertyBinding(elt, attrName, value); - return; - } - // Always write aria-* attrs because their getter and setter types differ. - if (!attrName.startsWith('aria-') && applyAttr([elt], attrName) === value) return; - applyAttr([elt], attrName, value); + if (readAttr(elt, attrName) === value) return; + writeAttr(elt, attrName, value); } let asTargets = t => t == null ? [] diff --git a/src/htmx.d.ts b/src/htmx.d.ts index f9b2bb014..c68505b09 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,89 +168,166 @@ export interface HtmxSwapContext { anchor?: string; } -export interface LiveClassProxy { - assign(classes: Record): void; - add(...tokens: string[]): void; - remove(...tokens: string[]): void; - toggle(token: string, force?: boolean): boolean; - replace(oldToken: string, newToken: string): boolean; - contains(token: string): boolean; - [name: string]: any; -} - -export interface LiveAttrProxy { - readonly data: Record; - readonly aria: Record; - readonly class: LiveClassProxy; - [name: string]: any; -} - -export interface LiveStateScope { - readonly attr: LiveAttrProxy; - readonly data: Record; - readonly aria: Record; - readonly class: LiveClassProxy; -} - -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; - /** Typed attributes on the selected elements. */ - readonly attr: LiveAttrProxy; - /** Typed `data-*` values on the selected elements. */ - readonly data?: Record; - /** Typed `aria-*` values on the selected elements. */ - readonly aria?: Record; - /** Class state and `classList` methods on the selected elements. */ - readonly class: LiveClassProxy; - /** Typed state on the nearest owner of each selected element. */ - readonly closest?: LiveStateScope; - /** - * 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; - /** Iterate over matched elements. */ - [Symbol.iterator](): IterableIterator; - /** DOM property passthrough: reads from first element, writes to all. */ - [key: string]: any; +export namespace HxLive { + /** A `DOMTokenList` with boolean class membership and grouped assignment. */ + export interface ClassProxy extends DOMTokenList { + assign(classes: Record): void; + [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; + [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; + /** Typed state on the nearest owner of 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: (string | string[])[]): 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. */ @@ -572,7 +651,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/test/tests/ext/hx-live.js b/test/tests/ext/hx-live.js index e469c016f..e5249d0ea 100644 --- a/test/tests/ext/hx-live.js +++ b/test/tests/ext/hx-live.js @@ -510,7 +510,6 @@ describe('hx-live extension', function () { it('q returns 0-count proxy when no match', function() { let proxy = htmx.live.q('.does-not-exist-anywhere'); proxy.count.should.equal(0); - assert.isUndefined(proxy.aria); }); it('q(element) wraps a single element', function() { @@ -911,40 +910,6 @@ describe('hx-live extension', function () { div.getAttribute('data-mode').should.equal('light'); }); - it('toggle(name, "a", "b", "c") cycles attribute through values (variadic form)', function() { - playground().innerHTML = '
    '; - let div = playground().querySelector('div'); - let p = htmx.live.q('div'); - p.toggle('data-mode', 'light', 'dark', 'auto'); - div.getAttribute('data-mode').should.equal('light'); - p.toggle('data-mode', 'light', 'dark', 'auto'); - div.getAttribute('data-mode').should.equal('dark'); - p.toggle('data-mode', 'light', 'dark', 'auto'); - div.getAttribute('data-mode').should.equal('auto'); - p.toggle('data-mode', 'light', 'dark', 'auto'); - div.getAttribute('data-mode').should.equal('light'); - }); - - it('toggle(name, "v", "") cycles between value and absent (variadic form)', function() { - playground().innerHTML = '
    '; - let div = playground().querySelector('div'); - let p = htmx.live.q('div'); - p.toggle('data-state', 'on', ''); - div.getAttribute('data-state').should.equal('on'); - p.toggle('data-state', 'on', ''); - div.hasAttribute('data-state').should.equal(false); - }); - - it('htmx.live.toggle(target, name, "a", "b") cycles across matches', function() { - playground().innerHTML = '
    '; - htmx.live.toggle('.t', 'data-view', 'grid', 'list'); - [...playground().querySelectorAll('.t')].map(e => e.getAttribute('data-view')) - .should.deep.equal(['grid', 'grid']); - htmx.live.toggle('.t', 'data-view', 'grid', 'list'); - [...playground().querySelectorAll('.t')].map(e => e.getAttribute('data-view')) - .should.deep.equal(['list', 'list']); - }); - it('toggle(name, [array]) cycles attribute through values (array form)', function() { playground().innerHTML = '
    '; let div = playground().querySelector('div'); @@ -1034,282 +999,6 @@ describe('hx-live extension', function () { assert.isFunction(htmx.live.toggle); }); - it('class reads, writes, and deletes class state', function() { - let button = createProcessedHTML(` - - `); - button.click(); - window.__classState.should.deep.equal([true, false]); - button.classList.contains('pending').should.equal(false); - button.classList.contains('done').should.equal(true); - button.classList.contains('is-active').should.equal(true); - button.classList.contains('remove-me').should.equal(false); - delete window.__classState; - }); - - it('bare class reads, writes, deletes, updates, and calls methods', function() { - let button = createProcessedHTML(` - - `); - button.click(); - window.__bareClassState.should.deep.equal([true, false]); - button.classList.contains('pending').should.equal(false); - button.classList.contains('done').should.equal(true); - button.classList.contains('is-active').should.equal(true); - button.classList.contains('remove-me').should.equal(false); - button.classList.contains('spin').should.equal(true); - button.classList.contains('active').should.equal(false); - delete window.__bareClassState; - }); - - it('bare class works in bindings and hx-live', async function() { - let elt = createProcessedHTML(` -
    -
    - `); - await htmx.timeout(5); - elt.hasAttribute('data-selected').should.equal(true); - elt.classList.contains('live').should.equal(true); - }); - - it('bare class rewriting skips other JavaScript syntax', function() { - let button = createProcessedHTML(` - - `); - button.click(); - window.__bareClassSyntax.should.deep.equal([ - true, - 'button', - 'prefix class.active', - true, - 'prefix class.active:yes' - ]); - button.classList.contains('qualified').should.equal(true); - delete window.__bareClassSyntax; - }); - - it('bare class works between division operators', function() { - let button = createProcessedHTML(` - - `); - button.click(); - window.__bareClassDivision.should.equal(4); - delete window.__bareClassDivision; - }); - - it('bare class works in deeply nested template expressions', function() { - let button = createProcessedHTML(` - - `); - button.click(); - window.__bareClassTemplate.should.equal('value:yes'); - delete window.__bareClassTemplate; - }); - - it("toggle('.name') toggles membership", function() { - let button = createProcessedHTML(` - - `); - button.click(); - button.classList.contains('active').should.equal(true); - button.click(); - button.classList.contains('active').should.equal(false); - }); - - it("take('.name') moves membership between siblings", function() { - playground().innerHTML = ` -
    - - -
    - `; - htmx.process(playground()); - let buttons = playground().querySelectorAll('button'); - buttons[1].click(); - buttons[0].classList.contains('active').should.equal(false); - buttons[1].classList.contains('active').should.equal(true); - }); - - it('q().class accesses only the first matched element', function() { - playground().innerHTML = ''; - let classes = htmx.live.q('#one').class; - classes.active = true; - classes.active.should.equal(true); - playground().querySelector('#one').classList.contains('active').should.equal(true); - playground().querySelector('#two').classList.contains('active').should.equal(false); - }); - - it('class supports keys and object spread', function() { - playground().innerHTML = ''; - let classes = htmx.live.q('#one').class; - Object.keys(classes).should.deep.equal(['active', 'pending']); - ({ ...classes }).should.deep.equal({ active: true, pending: true }); - }); - - it('class methods delegate to classList: add, remove, toggle, replace, contains', function() { - let button = createProcessedHTML(` - - `); - button.click(); - window.__r.should.deep.equal([true, false, true, false, true, false, false, true]); - delete window.__r; - }); - - it('class.assign adds truthy, removes falsy, leaves unmentioned', function() { - let button = createProcessedHTML(` - - `); - button.click(); - button.classList.contains('active').should.equal(true); - button.classList.contains('loading').should.equal(false); - button.classList.contains('keep').should.equal(true); - }); - - it('class.assign warns and no-ops on non-object arguments', function() { - let warnings = []; - let realWarn = console.warn; - console.warn = (...args) => warnings.push(args[0]); - try { - let button = createProcessedHTML(` - - `); - button.click(); - button.classList.contains('active').should.equal(false); - button.classList.contains('keep').should.equal(true); - } finally { - console.warn = realWarn; - } - warnings.length.should.equal(2); - warnings[0].should.contain('class.assign expects an object'); - }); - - it('removing the last class removes the class attribute', function() { - let button = createProcessedHTML(` - - `); - button.click(); - button.classList.length.should.equal(0); - button.hasAttribute('class').should.equal(false); - }); - - it('reserved method names: writes make classes, reads return methods, in sees classes', function() { - let button = createProcessedHTML(` - - `); - button.click(); - button.classList.contains('toggle').should.equal(false); // delete removed it - window.__kind.should.equal('function'); // read is the method - delete window.__kind; - - let classes = htmx.live.q('#res').class; - ('toggle' in classes).should.equal(false); // has-trap reads classes only - classes.toggle = true; // key write adds the class - button.classList.contains('toggle').should.equal(true); - ('toggle' in classes).should.equal(true); // in sees it once it is a class - (typeof classes.toggle).should.equal('function'); // read still returns the method - }); - - it('q().class writes hit all matches, reads use the first', function() { - playground().innerHTML = '
    '; - let classes = htmx.live.q('.x in #pl').class; - classes.add('a'); - let divs = playground().querySelectorAll('#pl .x'); - divs[0].classList.contains('a').should.equal(true); - divs[1].classList.contains('a').should.equal(true); - - classes.assign({ a: false, b: true }); - divs[0].classList.contains('a').should.equal(false); - divs[0].classList.contains('b').should.equal(true); - divs[1].classList.contains('a').should.equal(false); - divs[1].classList.contains('b').should.equal(true); - - classes.contains('a').should.equal(false); // reads first match - classes.contains('b').should.equal(true); - }); - - it('class proxy: symbols are undefined, spread skips method names', function() { - playground().innerHTML = ''; - let classes = htmx.live.q('#sp').class; - assert.isUndefined(classes[Symbol.iterator]); - assert.isUndefined(classes[Symbol.toPrimitive]); - Object.keys(classes).should.deep.equal(['active', 'pending']); - ({ ...classes }).should.deep.equal({ active: true, pending: true }); - }); - - it('hx-on:click class.add and class.assign work end-to-end', function() { - let button = createProcessedHTML(` - - `); - button.click(); - button.classList.contains('keep').should.equal(true); - button.classList.contains('spin').should.equal(true); - button.classList.contains('active').should.equal(true); - button.classList.contains('loading').should.equal(false); - }); - - it('class bindings react to class state', async function() { - let elt = createProcessedHTML(` -
    - `); - elt.classList.contains('visible').should.equal(true); - elt.classList.remove('selected'); - await htmx.timeout(5); - elt.classList.contains('visible').should.equal(false); - }); - it('htmx.live.toggle(target, name) toggles across matches', function() { playground().innerHTML = `
    @@ -1404,961 +1093,446 @@ describe('hx-live extension', function () { }); // ------------------------------------------------------------------------- - // attr proxy + // DOM state // ------------------------------------------------------------------------- - it('attr proxy getter: boolean attr returns boolean', function() { - playground().innerHTML = ''; - htmx.live.q('#a').attr.disabled.should.equal(true); - htmx.live.q('#b').attr.disabled.should.equal(false); - }); - - it('attr proxy getter: ARIA returns raw strings or null', function() { - playground().innerHTML = ` -
    - -
    -
    -
    -
    - `; - htmx.live.q('#a').attr['aria-expanded'].should.equal('true'); - htmx.live.q('#b').attr['aria-expanded'].should.equal('false'); - htmx.live.q('#c').attr['aria-current'].should.equal('page'); - htmx.live.q('#d').attr['aria-valuenow'].should.equal('50'); - htmx.live.q('#e').attr['aria-controls'].should.equal('menu help'); - assert.isNull(htmx.live.q('#f').attr['aria-label']); - }); - - it('attr proxy getter: regular attr returns string or null', function() { - playground().innerHTML = '
    '; - htmx.live.q('#a').attr['data-x'].should.equal('hello'); - assert.isNull(htmx.live.q('#b').attr['data-x']); - }); - - it('attr proxy getter: checked returns live state', function() { - playground().innerHTML = ''; - let inp = playground().querySelector('#a'); - inp.checked = true; - htmx.live.q('#a').attr.checked.should.equal(true); - }); - - it('attr proxy getter: value returns live state', function() { - playground().innerHTML = ''; - let inp = playground().querySelector('#a'); - inp.value = 'world'; - htmx.live.q('#a').attr.value.should.equal('world'); - }); - - it('attr proxy setter: boolean attr truthy sets, falsy removes', function() { - playground().innerHTML = ''; - htmx.live.q('#a').attr.disabled = true; - playground().querySelector('#a').hasAttribute('disabled').should.equal(true); - htmx.live.q('#a').attr.disabled = false; - playground().querySelector('#a').hasAttribute('disabled').should.equal(false); - }); - - it('attr proxy setter: ARIA stringifies values and null removes', function() { - playground().innerHTML = '
    '; - let div = playground().querySelector('#a'); - htmx.live.q('#a').attr['aria-expanded'] = true; - div.getAttribute('aria-expanded').should.equal('true'); - htmx.live.q('#a').attr['aria-expanded'] = false; - div.getAttribute('aria-expanded').should.equal('false'); - htmx.live.q('#a').attr['aria-expanded'] = null; - div.hasAttribute('aria-expanded').should.equal(false); - }); - - it('attr proxy setter: aria-* strings and numbers pass through', function() { - playground().innerHTML = '
    '; - // String values (tristate, tokens) pass through unchanged. - htmx.live.q('#a').attr['aria-pressed'] = 'mixed'; - playground().querySelector('#a').getAttribute('aria-pressed').should.equal('mixed'); - htmx.live.q('#b').attr['aria-current'] = 'page'; - playground().querySelector('#b').getAttribute('aria-current').should.equal('page'); - // Numbers stringify (e.g. aria-valuenow). - htmx.live.q('#c').attr['aria-valuenow'] = 50; - playground().querySelector('#c').getAttribute('aria-valuenow').should.equal('50'); - }); - - it('attr proxy setter: checked changes attribute and live state together', function() { - playground().innerHTML = ''; - let inp = playground().querySelector('#a'); - inp.checked = false; - htmx.live.q('#a').attr.checked = true; - inp.hasAttribute('checked').should.equal(true); - inp.checked.should.equal(true); - }); - - it('attr proxy setter: value changes the attribute and live state together', function() { - playground().innerHTML = ''; - let inp = playground().querySelector('#a'); - inp.value = 'live'; - htmx.live.q('#a').attr.value = 'set'; - inp.getAttribute('value').should.equal('set'); - inp.value.should.equal('set'); - htmx.live.q('#a').attr.value = null; - inp.hasAttribute('value').should.equal(false); - inp.value.should.equal(''); - }); + it('state bags read and write the current element', function() { + let button = createProcessedHTML(` +
    + +
    + `).querySelector('button'); - it('attr proxy setter: regular attr null removes', function() { - playground().innerHTML = '
    '; - htmx.live.q('#a').attr['data-x'] = null; - playground().querySelector('#a').hasAttribute('data-x').should.equal(false); - }); + button.click(); - it('attr proxy setter: regular attr stringifies non-string values', function() { - playground().innerHTML = '
    '; - htmx.live.q('#a').attr['data-x'] = 42; - playground().querySelector('#a').getAttribute('data-x').should.equal('42'); + 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 proxy setter: contenteditable false writes "false" string, not removes', function() { - playground().innerHTML = '
    '; - htmx.live.q('#a').attr.contenteditable = false; - playground().querySelector('#a').getAttribute('contenteditable').should.equal('false'); - }); + it('q() reads the first match and writes every match', function() { + playground().innerHTML = ''; + let items = htmx.live.q('.item'); - it('attr proxy setter: draggable false writes "false" string', function() { - playground().innerHTML = '
    '; - htmx.live.q('#a').attr.draggable = false; - playground().querySelector('#a').getAttribute('draggable').should.equal('false'); - }); + items.data.state.should.equal('first'); + items.attr.hidden = true; + items.aria.busy = false; + items.class.ready = true; - it('attr proxy setter: spellcheck false writes "false" string', function() { - playground().innerHTML = '
    '; - htmx.live.q('#a').attr.spellcheck = false; - playground().querySelector('#a').getAttribute('spellcheck').should.equal('false'); - }); - - it('attr proxy setter: contenteditable null removes attribute', function() { - playground().innerHTML = '
    '; - htmx.live.q('#a').attr.contenteditable = null; - playground().querySelector('#a').hasAttribute('contenteditable').should.equal(false); + 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('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('bare state is local except for cascading data', function() { + let button = createProcessedHTML(` + + `).querySelector('button'); - it('q().attr getter returns from first matched element', function() { - playground().innerHTML = '
    '; - htmx.live.q('.x').attr['data-i'].should.equal('a'); - }); + button.click(); - it('q().attr removes an attribute with delete', function() { - playground().innerHTML = ''; - delete htmx.live.q('.x').attr.role; - playground().querySelector('.x').hasAttribute('role').should.equal(false); + window.__ownership.should.deep.equal([false, undefined, false, 1, true, false, true, 1]); + delete window.__ownership; }); - it('q().attr uses lowercase HTML attribute names', function() { - playground().innerHTML = ''; - let attr = htmx.live.q('#input').attr; - - attr.readonly.should.equal(true); - attr.tabindex.should.equal(2); - attr.maxlength.should.equal(10); + it('q() state is local and q().closest state cascades', function() { + playground().innerHTML = ` + + `; + let item = htmx.live.q('#item'); - attr.readonly = false; - attr.tabindex = 3; - attr.maxlength = 20; + item.attr.hidden.should.equal(false); + assert.isUndefined(item.data.count); + assert.isUndefined(item.aria.busy); + item.class.active.should.equal(false); - let input = playground().querySelector('#input'); - input.hasAttribute('readonly').should.equal(false); - attr.tabindex.should.equal(3); - attr.maxlength.should.equal(20); + 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('q().attr accepts mixed-case HTML attribute names', function() { - playground().innerHTML = ''; - let attr = htmx.live.q('#input').attr; - - attr.readOnly.should.equal(true); - attr.TABINDEX.should.equal(2); - attr.MaxLength.should.equal(10); + it('closest writes to the current element when no owner exists', function() { + playground().innerHTML = ''; + let closest = htmx.live.q('#item').closest; - attr.READONLY = false; - attr.TabIndex = 3; - attr.MAXLENGTH = 20; + closest.attr.hidden = true; + closest.data.count = 1; + closest.aria.busy = false; + closest.class.active = true; - let input = playground().querySelector('#input'); - input.hasAttribute('readonly').should.equal(false); - input.getAttribute('tabindex').should.equal('3'); - input.getAttribute('maxlength').should.equal('20'); + playground().querySelector('#item').outerHTML.should.equal( + '' + ); }); - it('q() keeps native DOM property spelling separate from attr names', function() { - playground().innerHTML = ''; - let input = htmx.live.q('#input'); - - input.readOnly.should.equal(true); - input.tabIndex.should.equal(2); - input.maxLength.should.equal(10); - input.attr.readonly.should.equal(true); - input.attr.tabindex.should.equal(2); - input.attr.maxlength.should.equal(10); + it('closest updates each shared owner 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('q().closest.attr normalizes HTML names before resolving owners', function() { - playground().innerHTML = '
    '; - let attr = htmx.live.q('#input').closest.attr; + it('state bags delete local and closest state', function() { + playground().innerHTML = ` + + `; + let item = htmx.live.q('#item'); - attr.readonly.should.equal(true); - attr.TABINDEX.should.equal(2); - attr.READONLY = false; - attr.tabIndex = 3; + 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; - let fieldset = playground().querySelector('fieldset'); - fieldset.hasAttribute('readonly').should.equal(false); - fieldset.getAttribute('tabindex').should.equal('3'); + item.arr()[0].outerHTML.should.equal(''); + item.arr()[0].parentElement.attributes.length.should.equal(0); }); - it('q().attr preserves distinct SVG attribute casing', function() { - let svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - svg.setAttribute('viewBox', '0 0 10 10'); - playground().appendChild(svg); - let attr = htmx.live.q(svg).attr; + it('string boolean attributes have typed reads and writes', function() { + playground().innerHTML = '
    '; + let attr = htmx.live.q('#item').attr; - attr.viewBox.should.equal('0 0 10 10'); - assert.isNull(attr.viewbox); + [attr.contenteditable, attr.draggable, attr.spellcheck, attr.writingsuggestions] + .should.deep.equal(['plaintext-only', true, false, 123]); - attr.viewbox = '0 0 20 20'; + attr.spellcheck = value => !value; + attr.contenteditable = true; + attr.draggable = false; + delete attr.writingsuggestions; - svg.getAttribute('viewBox').should.equal('0 0 10 10'); - svg.getAttribute('viewbox').should.equal('0 0 20 20'); + 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('q().attr.data and q().data share the local data view', function() { - playground().innerHTML = ` -
    -
    - `; - let proxy = htmx.live.q('.x'); - assert.strictEqual(proxy.attr.data, proxy.data); - proxy.attr.data.count.should.equal(1); - proxy.data.count.should.equal(1); + it('generic attributes stringify booleans and remove null', function() { + playground().innerHTML = '
    '; + let attr = htmx.live.q('#item').attr; - proxy.attr.data.count = 3; - [...playground().querySelectorAll('.x')].map(e => e.dataset.count) - .should.deep.equal(['3', '3']); - - proxy.data.count = 4; - [...playground().querySelectorAll('.x')].map(e => e.dataset.count) - .should.deep.equal(['4', '4']); + 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('q().class and q().attr.class share the local class view', function() { - playground().innerHTML = '
    '; - let proxy = htmx.live.q('.x'); - assert.strictEqual(proxy.attr.class, proxy.class); - proxy.attr.class.active = true; - [...playground().querySelectorAll('.x')].every(e => e.classList.contains('active')) - .should.equal(true); - proxy.class.active = false; - [...playground().querySelectorAll('.x')].some(e => e.classList.contains('active')) - .should.equal(false); - }); + it('class is the typed class attribute', function() { + playground().innerHTML = ''; + let item = htmx.live.q('#item'); - it('q().aria and q().attr.aria share the local ARIA view', function() { - playground().innerHTML = '
    '; - let proxy = htmx.live.q('.x'); - assert.strictEqual(proxy.attr.aria, proxy.aria); - proxy.attr.aria.busy = true; - [...playground().querySelectorAll('.x')].map(e => e.getAttribute('aria-busy')) - .should.deep.equal(['true', 'true']); - proxy.aria.busy = false; - [...playground().querySelectorAll('.x')].map(e => e.getAttribute('aria-busy')) - .should.deep.equal(['false', 'false']); + (item.class === item.attr.class).should.equal(true); + (item.class === item.attr['class']).should.equal(true); + item.class.idle.should.equal(true); }); - it('q().data is local while bare data resolves the nearest owner', function() { - playground().innerHTML = ` -
    - -
    - `; - htmx.process(playground()); - let button = playground().querySelector('#button'); - button.click(); - window.__dataScopes.should.deep.equal(['owner', undefined]); - button.hasAttribute('data-state').should.equal(false); - playground().querySelector('section').dataset.state.should.equal('changed'); - delete window.__dataScopes; - }); + it('class supports grouped writes and DOMTokenList', function() { + playground().innerHTML = ''; + let classes = htmx.live.q('#item').class; - it('q().closest resolves one data owner per selected element and deduplicates writes', function() { - playground().innerHTML = ` -
    - - -
    -
    - -
    - `; - let a = playground().querySelector('#a'); - let b = playground().querySelector('#b'); - let writes = new Map([[a, 0], [b, 0]]); - let setAttribute = Element.prototype.setAttribute; - Element.prototype.setAttribute = function(name, value) { - if (name === 'data-state' && writes.has(this)) writes.set(this, writes.get(this) + 1); - return setAttribute.call(this, name, value); - }; - try { - htmx.live.q(playground().querySelectorAll('.hx-live-owner-item')).closest.data.state = 'open'; - } finally { - Element.prototype.setAttribute = setAttribute; - } - a.dataset.state.should.equal('open'); - b.dataset.state.should.equal('open'); - writes.get(a).should.equal(1); - writes.get(b).should.equal(1); - }); + classes.assign({ idle: false, ready: true }); + classes.add('selected'); - it('q().closest resolves each selected element for attributes, ARIA, and classes', function() { - playground().innerHTML = ` -
    - - -
    -
    - -
    - `; - let proxy = htmx.live.q(playground().querySelectorAll('.hx-live-owner-item')); - proxy.closest.attr.disabled = false; - proxy.closest.aria.busy = true; - proxy.closest.class.active = false; + 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'); - [...playground().querySelectorAll('section')].every(e => !e.hasAttribute('disabled')) - .should.equal(true); - [...playground().querySelectorAll('section')].map(e => e.getAttribute('aria-busy')) - .should.deep.equal(['true', 'true']); - [...playground().querySelectorAll('section')].every(e => !e.classList.contains('active')) - .should.equal(true); + classes.value = 'one two'; + [...playground().querySelector('#item').classList].should.deep.equal(['one', 'two']); }); - it('q().closest defers owner lookup until a state key is accessed', function() { - playground().innerHTML = '
    '; - let proxy = htmx.live.q(playground().querySelectorAll('.hx-live-owner-item')); - let calls = 0; - let closest = Element.prototype.closest; - Element.prototype.closest = function(...args) { - calls++; - return closest.apply(this, args); - }; - try { - let scope = proxy.closest; - let data = scope.data; - calls.should.equal(0); - data.state.should.equal('owner'); - calls.should.equal(1); - } finally { - Element.prototype.closest = closest; - } - }); + it('class methods write every match and return the first result', function() { + playground().innerHTML = ''; + let classes = htmx.live.q('.item').class; - it('q().closest reads the first selected owner for each state namespace', function() { - playground().innerHTML = ` - -
    - -
    - `; - let proxy = htmx.live.q(playground().querySelectorAll('.hx-live-owner-read-item')); - let scope = proxy.closest; - assert.strictEqual(scope, proxy.closest); - assert.strictEqual(scope.attr.data, scope.data); - assert.strictEqual(scope.attr.aria, scope.aria); - assert.strictEqual(scope.attr.class, scope.class); - assert.strictEqual(scope.data.state, 'first'); - assert.strictEqual(scope.aria.busy, false); - assert.strictEqual(scope.attr.role, 'tab'); - assert.strictEqual(scope.class.active, true); - }); + classes.toggle('active').should.equal(true); - it('q().closest reads undefined when no data owner exists', function() { - playground().innerHTML = ''; - playground().removeAttribute('data-state'); - assert.isUndefined(htmx.live.q(playground().querySelectorAll('.hx-live-owner-item')).closest.data.state); + [...playground().querySelectorAll('.item')].map(elt => elt.classList.contains('active')) + .should.deep.equal([true, false]); }); - it('q().closest writes data locally for every match when no owner exists', function() { - playground().innerHTML = ''; - playground().removeAttribute('data-state'); - htmx.live.q(playground().querySelectorAll('.hx-live-owner-item')).closest.data.state = 'created'; - [...playground().querySelectorAll('.hx-live-owner-item')].map(e => e.dataset.state) - .should.deep.equal(['created', 'created']); - }); + it('native class members win on read and class membership remains accessible', function() { + playground().innerHTML = ''; + let classes = htmx.live.q('#item').class; - it('q().closest writes attr and class locally for every match when no owner exists', function() { - playground().innerHTML = ''; - let proxy = htmx.live.q(playground().querySelectorAll('.hx-live-owner-item')); - proxy.closest.attr.role = 'button'; - proxy.closest.class.active = true; + (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); - [...playground().querySelectorAll('.hx-live-owner-item')].every(e => e.getAttribute('role') === 'button') - .should.equal(true); - [...playground().querySelectorAll('.hx-live-owner-item')].every(e => e.classList.contains('active')) - .should.equal(true); + classes.toggle = false; + classes.contains('toggle').should.equal(false); }); - it('q().closest deletes nothing when no owner exists', function() { - playground().innerHTML = ''; - playground().removeAttribute('data-state'); - let proxy = htmx.live.q(playground().querySelectorAll('.hx-live-owner-item')); - delete proxy.closest.data.state; - delete proxy.closest.aria.busy; - delete proxy.closest.attr.role; - delete proxy.closest.class.active; + it('class state is empty when q() has no matches', function() { + let classes = htmx.live.q('.missing').class; - [...playground().querySelectorAll('.hx-live-owner-item')].every(e => - !e.hasAttribute('data-state') && - !e.hasAttribute('aria-busy') && - !e.hasAttribute('role') && - !e.classList.contains('active')) - .should.equal(true); + assert.isUndefined(classes.active); + classes.active = true; + [...classes].should.deep.equal([]); }); - 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('state reads typed DOM values', function() { + playground().innerHTML = ''; + let item = htmx.live.q('#item'); - it('attr in hx-live expression operates on current element', async function() { - let elt = createProcessedHTML( - `` - ); - await htmx.timeout(5); - elt.hasAttribute('data-flipped').should.equal(true); + 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']); }); - // ------------------------------------------------------------------------- - // matches() scope helper - // ------------------------------------------------------------------------- - - it('matches() is available in hx-on scope bound to element', function() { - playground().innerHTML = ''; - htmx.process(playground()); - playground().querySelector('#i').click(); - window.__matchesLive.should.equal(true); - delete window.__matchesLive; - }); + 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' + ]; - it('matches() in hx-live expression operates on current element', async function() { - playground().innerHTML = ` - -
    - -
    - `; - htmx.process(playground()); - await htmx.timeout(5); - let div = playground().querySelector('[hx-live]'); - div.dataset.has.should.equal('true'); - }); + 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); + } - it('htmx.live.attr is not exposed on the public API', function() { - assert.isUndefined(htmx.live.attr); + 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'); + } }); - // ------------------------------------------------------------------------- - // cascading ARIA proxy - // ------------------------------------------------------------------------- + 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' + }; - it('aria.foo reacts to the closest ARIA state', async function() { - playground().innerHTML = ` -
    -
    - -
    -
    - `; - htmx.process(playground()); - let button = playground().querySelector('button'); - button.disabled.should.equal(false); - button.click(); - await htmx.timeout(5); - button.disabled.should.equal(true); - playground().querySelector('form').getAttribute('aria-busy').should.equal('true'); - playground().querySelector('section').getAttribute('aria-busy').should.equal('true'); - }); + for (let [name, value] of Object.entries(tokens)) { + element.setAttribute('aria-' + name, value); + aria[name].should.equal(value); + } - it("toggle('aria-name', values) cycles explicit values", function() { - playground().innerHTML = ` -
    - -
    - `; - htmx.process(playground()); - let owner = playground().querySelector('div'); - let button = playground().querySelector('button'); - button.click(); - owner.getAttribute('aria-sort').should.equal('descending'); - button.click(); - owner.getAttribute('aria-sort').should.equal('other'); + 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("take('aria-name') claims sibling state", function() { - playground().innerHTML = ` -
    - - -
    - `; - htmx.process(playground()); - let tabs = playground().querySelectorAll('[role=tab]'); - tabs[1].click(); - tabs[0].getAttribute('aria-selected').should.equal('false'); - tabs[1].getAttribute('aria-selected').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' + ]; - it('q().aria uses only its first match', function() { - playground().innerHTML = ` -
    -
    -
    - `; - let aria = htmx.live.q('#form').aria; - aria.checked.should.equal(false); - assert.isUndefined(aria.busy); - assert.isUndefined(aria.controls); - assert.isTrue(delete aria.label); - - let ownerAria = htmx.live.q('#form').q('closest [aria-busy]').aria; - ownerAria.busy.should.equal(false); - ownerAria.busy = true; - aria.checked = true; - aria.busy = false; - - playground().querySelector('form').getAttribute('aria-checked').should.equal('true'); - playground().querySelector('form').getAttribute('aria-busy').should.equal('false'); - playground().querySelector('section').getAttribute('aria-busy').should.equal('true'); - - aria.checked = null; - delete ownerAria.busy; - playground().querySelector('form').hasAttribute('aria-checked').should.equal(false); - playground().querySelector('section').hasAttribute('aria-busy').should.equal(false); - }); - - it('returns every boolean-like ARIA attribute as a boolean', function() { - playground().innerHTML = '
    '; - let values = { - atomic: true, - busy: false, - checked: true, - current: false, - disabled: true, - expanded: false, - grabbed: true, - hasPopup: false, - hidden: true, - invalid: false, - modal: true, - multiline: false, - multiselectable: true, - pressed: false, - readonly: true, - required: false, - selected: true - }; - let state = playground().querySelector('#booleans'); - for (let [name, value] of Object.entries(values)) { - state.setAttribute('aria-' + name.toLowerCase(), String(value)); - } - let aria = htmx.live.q(state).aria; - for (let [name, value] of Object.entries(values)) { - aria[name].should.equal(value); + for (let name of integers) { + element.setAttribute('aria-' + name, '2'); + aria[name].should.equal(2); } - }); - it('returns every numeric ARIA attribute as a number', function() { - playground().innerHTML = '
    '; - let values = { - colCount: 3, - colIndex: 2, - colSpan: 1, - level: 4, - posInSet: 5, - rowCount: 6, - rowIndex: 7, - rowSpan: 2, - setSize: 8, - valueMax: 100, - valueMin: 0, - valueNow: 51.5 + 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' }; - let state = playground().querySelector('#state'); - for (let [name, value] of Object.entries(values)) { - let attributeValue = name === 'valueNow' ? ' 51.5 ' : String(value); - state.setAttribute('aria-' + name.toLowerCase(), attributeValue); - } - let aria = htmx.live.q(state).aria; - for (let [name, value] of Object.entries(values)) { + + for (let [name, value] of Object.entries(strings)) { + element.setAttribute('aria-' + name, value); aria[name].should.equal(value); } }); - it('preserves missing and invalid numeric ARIA values', function() { - playground().innerHTML = ` -
    -
    - `; - let aria = htmx.live.q('#invalid-numbers').aria; - aria.colSpan.should.equal('1.5'); - aria.level.should.equal('many'); - aria.valueMax.should.equal(''); - aria.valueMin.should.equal('Infinity'); - aria.valueNow.should.equal('unknown'); - assert.isUndefined(aria.rowCount); - }); - - it('does not coerce string ARIA attributes that look typed', function() { - playground().innerHTML = ` -
    -
    - `; - let aria = htmx.live.q('#strings').aria; - aria.description.should.equal('true'); - aria.label.should.equal('false'); - aria.valueText.should.equal('51'); - aria.activeDescendant.should.equal('item'); - aria.details.should.equal('details'); - aria.errorMessage.should.equal('error'); - }); - - it('preserves non-boolean ARIA tokens', function() { - playground().innerHTML = '
    '; - let aria = htmx.live.q('#tokens').aria; - aria.checked.should.equal('mixed'); - aria.current.should.equal('page'); - aria.invalid.should.equal('spelling'); - }); - - it('returns ARIA list attributes as arrays and joins array writes', function() { - playground().innerHTML = '
    '; - let values = { - controls: ['menu', 'help'], - describedBy: ['hint', 'error'], - dropEffect: ['copy', 'move'], - flowTo: ['next', 'later'], - labelledBy: ['title', 'subtitle'], - owns: ['item-1', 'item-2'], + 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'] }; - let state = playground().querySelector('#lists'); - for (let [name, value] of Object.entries(values)) { - state.setAttribute('aria-' + name.toLowerCase(), value.join(' ')); - } - state.setAttribute('aria-controls', ' menu help '); - let aria = htmx.live.q(state).aria; - for (let [name, value] of Object.entries(values)) { + + for (let [name, value] of Object.entries(lists)) { + element.setAttribute('aria-' + name, value.join(' ')); aria[name].should.deep.equal(value); } - - aria.controls = ['dialog', 'help']; - aria.relevant = []; - aria.owns = 'item-3 item-4'; - state.getAttribute('aria-controls').should.equal('dialog help'); - state.getAttribute('aria-relevant').should.equal(''); - state.getAttribute('aria-owns').should.equal('item-3 item-4'); - aria.relevant.should.deep.equal([]); - aria.owns.should.deep.equal(['item-3', 'item-4']); - }); - - it('writes missing ARIA attributes on this and deletes the closest match', function() { - playground().innerHTML = ` -
    - -
    - `; - htmx.process(playground()); - let button = playground().querySelector('button'); - button.click(); - playground().querySelector('div').getAttribute('aria-valuenow').should.equal('51'); - playground().querySelector('div').hasAttribute('aria-current').should.equal(false); - button.getAttribute('aria-label').should.equal('Save'); - }); - - it('q(this).aria only accesses the current element', function() { - playground().innerHTML = ` -
    - -
    - `; - htmx.process(playground()); - let button = playground().querySelector('button'); - button.click(); - window.__localState.should.deep.equal([undefined, true]); - window.__localAfter.should.equal(false); - button.getAttribute('aria-busy').should.equal('false'); - playground().querySelector('section').getAttribute('aria-busy').should.equal('true'); - delete window.__localState; - delete window.__localAfter; - }); - - it('q(this).aria preserves application element properties after await', async function() { - playground().innerHTML = ` -
    - -
    - `; - htmx.process(playground()); - let button = playground().querySelector('button'); - let applicationState = { owner: 'app' }; - button.aria = applicationState; - button.click(); - await htmx.timeout(10); - window.__sameThis.should.equal(true); - window.__closestId.should.equal('owner'); - button.aria.should.equal(applicationState); - button.getAttribute('aria-busy').should.equal('true'); - delete window.__sameThis; - delete window.__closestId; }); - // ------------------------------------------------------------------------- - // cascading data proxy - // ------------------------------------------------------------------------- + it('ARIA writes serialize typed values and updater results', function() { + playground().innerHTML = ''; + let element = playground().querySelector('#item'); + let aria = htmx.live.q('#item').aria; - it("toggle('data-name', values) cycles explicit values", function() { - playground().innerHTML = ` -
    - -
    - `; - htmx.process(playground()); - let owner = playground().querySelector('div'); - let button = playground().querySelector('button'); - button.click(); - owner.dataset.view.should.equal('list'); - button.click(); - owner.dataset.view.should.equal('grid'); - }); + aria.expanded = expanded => !expanded; + aria.valueNow = 2.5; + aria.current = 'page'; + aria.controls = ['menu', 'help']; + aria.label = 'true'; - it("toggle('data-name', \"a\", \"b\") cycles values passed as separate arguments", function() { - playground().innerHTML = ` -
    - -
    - `; - htmx.process(playground()); - let owner = playground().querySelector('div'); - let button = playground().querySelector('button'); - button.click(); - owner.dataset.view.should.equal('list'); - button.click(); - owner.dataset.view.should.equal('grid'); + 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("toggle('data-name') toggles attribute presence", function() { - let button = createProcessedHTML(` - - `); - button.click(); - button.hasAttribute('data-active').should.equal(false); - button.click(); - button.dataset.active.should.equal(''); - }); + it('updater functions receive each current value', function() { + playground().innerHTML = ''; - it('q(this).data.active = !q(this).data.active flips a typed boolean', function() { - let button = createProcessedHTML(` - - `); - button.click(); - button.dataset.active.should.equal('true'); - button.click(); - button.dataset.active.should.equal('false'); - }); + htmx.live.q('.item').value = value => value.trim(); + htmx.live.q('.item').attr.title = title => title || 'ready'; - it('data.active = undefined removes the attribute', function() { - let button = createProcessedHTML(` - - `); - button.click(); - button.hasAttribute('data-active').should.equal(false); + [...playground().querySelectorAll('.item')].map(elt => [elt.value, elt.title]) + .should.deep.equal([['a', 'ready'], ['b', 'ready']]); }); - it("take('data-name') moves sibling state", function() { - playground().innerHTML = ` -
    - - -
    - `; - htmx.process(playground()); - let buttons = playground().querySelectorAll('button'); - buttons[1].click(); - buttons[0].hasAttribute('data-active').should.equal(false); - buttons[1].hasAttribute('data-active').should.equal(true); - }); - - it('reads valid JSON values and preserves other data attribute text', function() { - playground().innerHTML = '
    '; - let state = playground().querySelector('#state'); - let data = htmx.live.q(state).data; - let values = [ - { label: 'empty string', attribute: '', value: '' }, - { label: 'true', attribute: 'true', value: true }, - { label: 'false', attribute: 'false', value: false }, - { label: 'null', attribute: 'null', value: null }, - { label: 'integer', attribute: '42', value: 42 }, - { label: 'float', attribute: '3.14', value: 3.14 }, - { label: 'negative', attribute: '-0.5', value: -0.5 }, - { label: 'exponent', attribute: '1e3', value: 1000 }, - { label: 'whitespace', attribute: ' 42 ', value: 42 }, - { label: 'object', attribute: '{"count":1}', value: { count: 1 } }, - { label: 'array', attribute: '["one"]', value: ['one'] }, - { label: 'JSON string', attribute: '"hello"', value: 'hello' }, - { label: 'plain string', attribute: 'hello', value: 'hello' }, - { label: 'leading zero', attribute: '01', value: '01' }, - { label: 'leading decimal point', attribute: '.5', value: '.5' }, - { label: 'NaN text', attribute: 'NaN', value: 'NaN' }, - { label: 'Infinity text', attribute: 'Infinity', value: 'Infinity' } - ]; - - assert.isUndefined(data.value); - for (let { label, attribute, value } of values) { - state.setAttribute('data-value', attribute); - state.dataset.value.should.equal(attribute, label + ' raw value'); - assert.deepEqual(data.value, value, label + ' normalized value'); - } - }); + it('state updater functions receive typed current values', function() { + playground().innerHTML = ''; + let item = htmx.live.q('#item'); - it('serializes assigned values before reading them back', function() { - playground().innerHTML = '
    '; - let state = playground().querySelector('#state'); - let data = htmx.live.q(state).data; - let values = [ - { label: 'empty string', input: '', attribute: '', value: '' }, - { label: 'plain string', input: 'hello', attribute: 'hello', value: 'hello' }, - { label: 'true string', input: 'true', attribute: 'true', value: true }, - { label: 'true', input: true, attribute: 'true', value: true }, - { label: 'false string', input: 'false', attribute: 'false', value: false }, - { label: 'false', input: false, attribute: 'false', value: false }, - { label: 'number string', input: '42', attribute: '42', value: 42 }, - { label: 'number', input: 42, attribute: '42', value: 42 }, - { label: 'float', input: 3.14, attribute: '3.14', value: 3.14 }, - { label: 'negative', input: -0.5, attribute: '-0.5', value: -0.5 }, - { label: 'null string', input: 'null', attribute: 'null', value: null }, - { label: 'null', input: null, attribute: 'null', value: null }, - { label: 'object string', input: '{"count":1}', attribute: '{"count":1}', value: { count: 1 } }, - { label: 'object', input: { count: 1 }, attribute: '{"count":1}', value: { count: 1 } }, - { label: 'array string', input: '["one"]', attribute: '["one"]', value: ['one'] }, - { label: 'array', input: ['one'], attribute: '["one"]', value: ['one'] }, - { label: 'JSON string', input: '"hello"', attribute: '"hello"', value: 'hello' } - ]; + item.attr.hidden = hidden => !hidden; + item.data.count = count => count + 1; + item.aria.busy = busy => !busy; + item.class.active = active => !active; - for (let { label, input, attribute, value } of values) { - data.value = input; - state.dataset.value.should.equal(attribute, label + ' stored value'); - assert.deepEqual(data.value, value, label + ' normalized value'); - } + 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('q().data only accesses the selected element', function() { - playground().innerHTML = ` -
    -
    -
    - `; - let data = htmx.live.q('#form').data; - data.ready.should.equal(false); - assert.isUndefined(data.count); - ({ ...data }).should.deep.equal({ ready: false }); - - let ownerData = htmx.live.q('#form').q('closest [data-count]').data; - ownerData.count.should.equal(1); - ownerData.count = 2; - data.ready = true; - data.count = 3; - - playground().querySelector('form').dataset.ready.should.equal('true'); - playground().querySelector('form').dataset.count.should.equal('3'); - playground().querySelector('section').dataset.count.should.equal('2'); + it('toggle cycles through variadic values', function() { + playground().innerHTML = ''; - delete data.ready; - delete ownerData.count; - playground().querySelector('form').hasAttribute('data-ready').should.equal(false); - playground().querySelector('section').hasAttribute('data-count').should.equal(false); - }); + htmx.live.q('#item').toggle('data-view', 'grid', 'list'); - it('q(this).data only accesses the current element after await', async function() { - playground().innerHTML = ` -
    - -
    - `; - htmx.process(playground()); - let button = playground().querySelector('button'); - button.click(); - await htmx.timeout(10); - window.__dataState.should.deep.equal([undefined, 1]); - button.dataset.count.should.equal('2'); - playground().querySelector('section').dataset.count.should.equal('1'); - delete window.__dataState; + playground().querySelector('#item').dataset.view.should.equal('list'); }); - it('q(this).data preserves native element data properties', function() { - playground().innerHTML = ` - - `; + it('matches() is available in hx-on scope bound to element', function() { + playground().innerHTML = ''; htmx.process(playground()); - let object = playground().querySelector('object'); - object.click(); - object.getAttribute('data').should.equal('/chart.svg'); - object.dataset.ready.should.equal('true'); + playground().querySelector('#i').click(); + window.__matchesLive.should.equal(true); + delete window.__matchesLive; }); - it('delete data.foo removes the closest matching attribute', function() { + it('matches() in hx-live expression operates on current element', async function() { playground().innerHTML = ` -
    - -
    + +
    + +
    `; htmx.process(playground()); - playground().querySelector('button').click(); - playground().querySelector('section').hasAttribute('data-state').should.equal(false); + await htmx.timeout(5); + let div = playground().querySelector('[hx-live]'); + div.dataset.has.should.equal('true'); }); - it('data.foo reads this.dataset.foo when present locally', async function() { playground().innerHTML = `
    x
    + hx-on:click="this.dataset.v = data.foo">x
    `; htmx.process(playground()); let elt = playground().querySelector('#me'); @@ -2370,7 +1544,7 @@ describe('hx-live extension', function () { playground().innerHTML = `
    - x + x
    `; @@ -2382,7 +1556,7 @@ describe('hx-live extension', function () { it('data.foo returns undefined when no ancestor has it', async function() { playground().innerHTML = ` -
    x
    +
    x
    `; htmx.process(playground()); let elt = playground().querySelector('#me'); @@ -2394,7 +1568,7 @@ describe('hx-live extension', function () { playground().innerHTML = `
    - x + x
    `; @@ -2408,7 +1582,7 @@ describe('hx-live extension', function () { playground().innerHTML = `
    - +
    `; @@ -2421,93 +1595,9 @@ describe('hx-live extension', function () { section.dataset.counter.should.equal('2'); }); - it('functional data assignment updates a selected owner', function() { - playground().innerHTML = ` -
    -
    - -
    -
    - `; - htmx.process(playground()); - playground().querySelector('button').click(); - JSON.parse(playground().querySelector('section').dataset.cart).should.deep.equal([ - { id: '1' }, - { id: '2' } - ]); - }); - - it('functional data assignment can initialize a missing value', function() { - playground().innerHTML = ''; - let button = playground().querySelector('button'); - htmx.live.q(button).data.items = items => [...(items || []), 'one']; - button.dataset.items.should.equal('["one"]'); - }); - - it('functional data assignment runs once and leaves the value when it throws', function() { - playground().innerHTML = '
    '; - let data = htmx.live.q('#state').data; - let calls = 0; - data.count = count => { calls++; return count + 1; }; - calls.should.equal(1); - data.count.should.equal(2); - - let fail = () => { throw new Error('no update'); }; - assert.throws(() => { data.count = fail; }, 'no update'); - data.count.should.equal(2); - - assert.throws(() => { data.count = async count => count + 1; }, 'assigned function must return a value, not a promise'); - data.count.should.equal(2); - }); - - it('functional property assignment via q() updates a DOM property', function() { - playground().innerHTML = ''; - let q = htmx.live.q('#panel'); - q.hidden = hidden => !hidden; - playground().querySelector('#panel').hidden.should.equal(false); - }); - - it('functional property assignment passes the current typed value', function() { - playground().innerHTML = ''; - let q = htmx.live.q('#name'); - let seen; - q.value = v => { seen = v; return v + ' world'; }; - seen.should.equal('hello'); - playground().querySelector('#name').value.should.equal('hello world'); - }); - - it('q() property setter stores an on* handler as a literal function', function() { - playground().innerHTML = ''; - let handler = () => 42; - htmx.live.q('#btn').onclick = handler; - playground().querySelector('#btn').onclick.should.equal(handler); - }); - - it('functional attr assignment updates an attribute', function() { - playground().innerHTML = ''; - htmx.live.q('#box').attr.hidden = hidden => !hidden; - playground().querySelector('#box').hasAttribute('hidden').should.equal(false); - }); - - it('functional attr assignment reads the current typed value', function() { - playground().innerHTML = ''; - let seen; - htmx.live.q('#box').attr.hidden = h => { seen = h; return h; }; - seen.should.equal(true); - }); - - it('functional class assignment toggles correctly', function() { - playground().innerHTML = '
    '; - htmx.live.q('#box').class.on = on => !on; - playground().querySelector('#box').classList.contains('on').should.equal(false); - }); - it('data.foo = "x" writes to this when no ancestor has data-foo', async function() { playground().innerHTML = ` - + `; htmx.process(playground()); let btn = playground().querySelector('#me'); @@ -2518,7 +1608,7 @@ describe('hx-live extension', function () { it('data.foo++ works (auto-coerces to number)', async function() { playground().innerHTML = `
    - +
    `; htmx.process(playground()); @@ -2531,7 +1621,7 @@ describe('hx-live extension', function () { it('data proxy: boolean round-trips through JSON', async function() { playground().innerHTML = `
    - +
    `; htmx.process(playground()); @@ -2546,7 +1636,7 @@ describe('hx-live extension', function () { it('data proxy: number round-trips through JSON', async function() { playground().innerHTML = `
    - +
    `; htmx.process(playground()); @@ -2562,8 +1652,8 @@ describe('hx-live extension', function () { it('data proxy: object round-trips through JSON', async function() { playground().innerHTML = `
    - - read + + read
    `; htmx.process(playground()); @@ -2581,8 +1671,8 @@ describe('hx-live extension', function () { it('data proxy: array round-trips through JSON', async function() { playground().innerHTML = `
    - - count + + count
    `; htmx.process(playground()); @@ -2602,7 +1692,7 @@ describe('hx-live extension', function () { it('data proxy: plain string stays as string', async function() { playground().innerHTML = `
    - x + x
    `; htmx.process(playground()); @@ -2614,7 +1704,7 @@ describe('hx-live extension', function () { it('data proxy: null round-trips through JSON', async function() { playground().innerHTML = `
    - x + x
    `; htmx.process(playground()); @@ -2626,7 +1716,7 @@ describe('hx-live extension', function () { it('with (data) { foo++ } increments cascading value', async function() { playground().innerHTML = `
    - +
    `; htmx.process(playground()); @@ -2640,7 +1730,7 @@ describe('hx-live extension', function () { playground().innerHTML = `
    @@ -2655,7 +1745,7 @@ describe('hx-live extension', function () { it('data.kebabKey camelCase translation works', async function() { playground().innerHTML = `
    - x + x
    `; htmx.process(playground()); @@ -2668,7 +1758,7 @@ describe('hx-live extension', function () { playground().innerHTML = `
    - +
    `; @@ -2686,7 +1776,7 @@ describe('hx-live extension', function () {
    @@ -2705,9 +1795,9 @@ describe('hx-live extension', function () {
    @@ -2724,7 +1814,7 @@ describe('hx-live extension', function () { playground().innerHTML = `
    @@ -2740,7 +1830,7 @@ describe('hx-live extension', function () { it('data is reactive in :attr expressions (re-runs on ancestor data change)', async function() { playground().innerHTML = `
    -
    +
    `; htmx.process(playground()); @@ -2762,12 +1852,12 @@ describe('hx-live extension', function () {
    data.message = message; data.level = level; await timeout(3000); - closest.data.message = ''" - :text="closest.data.message" - :.success="closest.data.level === 'success'" - :.error="closest.data.level === 'error'">
    + data.message = ''" + :text="data.message" + :.success="data.level === 'success'" + :.error="data.level === 'error'"> `; htmx.process(playground()); let source = playground().querySelector('#source'); @@ -2841,7 +1931,7 @@ describe('hx-live extension', function () { btn.hasAttribute('disabled').should.equal(true); }); - it(':aria-expanded writes boolean strings', async function() { + it(':aria-expanded writes "true"/"false", never removes', async function() { playground().innerHTML = ` @@ -3021,7 +2111,7 @@ describe('hx-live extension', function () { it(':style replaces an old shorthand with a new longhand', async function() { playground().innerHTML = ` -
    `; @@ -3326,53 +2416,4 @@ describe('hx-live extension', function () { }); - // ------------------------------------------------------------------------- - // q() property setter. Never assert against a function value directly: the - // test runner cannot serialize a function in a failure message and the - // session hangs. Compare identity as a boolean instead. - // ------------------------------------------------------------------------- - - describe('q() property setter', function() { - - it('q() setter stores a function on a property that already holds one', function() { - playground().innerHTML = '
    '; - let grid = playground().querySelector('#grid'); - grid.rowRenderer = () => 'old'; - let next = () => 'new'; - htmx.live.q('#grid').rowRenderer = next; - (grid.rowRenderer === next).should.equal(true, 'stored the function, not its return value'); - }); - - it('q() setter stores a function on an unset custom property', function() { - playground().innerHTML = '
    '; - let grid = playground().querySelector('#grid'); - let fn = () => 'cell'; - htmx.live.q('#grid').renderCell = fn; - (grid.renderCell === fn).should.equal(true, 'stored the function, not its return value'); - }); - - it('writing .value leaves defaultValue intact for dirty tracking', function() { - playground().innerHTML = ''; - let input = playground().querySelector('#i'); - htmx.live.q('#i').value = 'edited'; - input.value.should.equal('edited'); - input.defaultValue.should.equal('original'); - }); - - it('morph preserves a JS-written value when the server attribute is unchanged', async function() { - playground().innerHTML = '
    '; - htmx.process(playground()); - htmx.live.q('#i').value = 'typed by user'; - await htmx.swap({ - target: '#wrap', - text: '
    ', - swap: 'outerMorph', - sourceElement: playground() - }); - await htmx.timeout(5); - playground().querySelector('#i').value.should.equal('typed by user'); - }); - - }); - }); diff --git a/www/src/content/extensions/06-hx-live.md b/www/src/content/extensions/06-hx-live.md index 387d4eff0..e2aacdaf9 100644 --- a/www/src/content/extensions/06-hx-live.md +++ b/www/src/content/extensions/06-hx-live.md @@ -63,7 +63,7 @@ Reads use the first match. Writes update every match: q('.item').aria.busy = true ``` -See [`q()`](#q) for directional selectors, scoped selectors, and chained queries. +See [`q()`](#q) for its full selector grammar. ### Handle an Event @@ -278,7 +278,7 @@ 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 inside `hx-live` expressions and [`hx-on`](/reference/attributes/hx-on) event handlers. The [Public API](#public-api) lists the helpers available to regular JavaScript. ```js htmx.live.q('.row').attr.hidden = true; @@ -342,9 +342,9 @@ 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 @@ -354,6 +354,21 @@ q('.tab').trigger('select', { id: 1 }) // CustomEvent on each q('.list').insert('end', '
  • new
  • ') // before / after / start / end ``` +### Ownership + +Bare `attr.*`, `aria.*`, and `class.*` use the current element. Bare `data.*` uses the nearest element carrying that `data-*` attribute. + +`q(...)` makes every state bag local to the selected elements. Add `.closest` to use the nearest owner instead: + +| Current expression | Selected elements | Nearest owner | +|---|---|---| +| `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 owner exists. A closest delete does nothing when no owner exists. When several selected elements share an owner, hx-live updates it once. + ### `attr` Read and write HTML attributes on this element. @@ -384,8 +399,7 @@ delete attr['data-x'] attr['data-x'] = null ``` -Use [`class.*`](#class) and [`aria.*`](#aria) for the typed aliases. Use native -DOM methods when you need exact raw attribute text. +Use [`class.*`](#class) and [`aria.*`](#aria) for class and ARIA state. ### `toggle(name, values?)` @@ -407,11 +421,6 @@ toggle('data-view', 'grid|list|table') toggle('data-view', ['grid', 'list', 'table']) ``` -```js -toggle('aria-expanded') -toggle('data-view', 'grid', 'list') -``` - ### `take(name, scope?)` Move a class or attribute from siblings to this element. Pass a `scope` selector to widen or restrict the source set. @@ -422,14 +431,9 @@ take('aria-current', 'nav a') // become the current nav item take('.active') // implicit scope: parent element's subtree ``` -```js -take('aria-selected') -take('.active') -``` - ### `class` -Read and write class membership on this element: +`class`, `attr.class`, and `attr['class']` return the same class state. Read and write membership with boolean properties: ```html '; + 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('class methods write every match and return the first result', function() { playground().innerHTML = ''; let classes = htmx.live.q('.item').class; diff --git a/www/package.json b/www/package.json index c3cf82584..71f5cfe56 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/tests/types/hx-live.ts b/www/tests/types/hx-live.ts new file mode 100644 index 000000000..d1a7914e3 --- /dev/null +++ b/www/tests/types/hx-live.ts @@ -0,0 +1,28 @@ +import htmx, { type HxLive } from '../../../src/htmx'; + +const live = htmx.live!; +const aria = live.q('#item').aria!; + +const busy: boolean | undefined = aria.busy; +aria.busy = true; +aria.busy = current => !current; +aria.current = 'page'; +aria.controls = ['label', 'hint']; + +// @ts-expect-error ARIA booleans reject strings +aria.busy = 'true'; +// @ts-expect-error ARIA tokens reject unknown values +aria.current = 'other'; +// @ts-expect-error ARIA lists reject scalar strings +aria.controls = 'label'; +// @ts-expect-error updater results must match the attribute +aria.busy = () => 'true'; + +const data: HxLive.DataProxy = live.q('#item').data!; +const classes: HxLive.ClassProxy = live.q('#item').class!; +const increment: HxLive.Updater = value => value + 1; + +void busy; +void data; +void classes; +void increment; diff --git a/www/tests/types/tsconfig.json b/www/tests/types/tsconfig.json new file mode 100644 index 000000000..4fc14bda4 --- /dev/null +++ b/www/tests/types/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "lib": ["DOM", "ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "strict": true, + "types": [] + }, + "files": ["hx-live.ts"] +} From bb8c8aaa28e2414986160656c61f73981a54be33 Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Fri, 14 Aug 2026 23:39:57 +0300 Subject: [PATCH 22/39] Type numeric HTML attributes --- src/ext/hx-live.js | 7 ++--- test/tests/ext/hx-live.js | 40 ++++++++++++++++++++++++ www/src/content/extensions/06-hx-live.md | 2 +- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/ext/hx-live.js b/src/ext/hx-live.js index fffc94a2e..c28bf6801 100644 --- a/src/ext/hx-live.js +++ b/src/ext/hx-live.js @@ -72,8 +72,7 @@ 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 checked selected'.split(' ')); let PROPERTY_BINDING_ATTRS = new Set(['checked','value','selected']); let STRING_BOOLEAN_ATTRS = new Set(['contenteditable','draggable','spellcheck','writingsuggestions']); - let NUMERIC_INPUT_TYPES = new Set('number range'.split(' ')); - let NUMERIC_ATTRS = new Set('tabindex colspan rowspan maxlength minlength size span start rows cols width height'.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(' ')); function normalizeAttrName(elt, name) { return elt instanceof HTMLElement ? name.toLowerCase() : name; @@ -83,14 +82,14 @@ 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' && NUMERIC_INPUT_TYPES.has(element.type)) { + 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)) return element.hasAttribute(name); let value = element.getAttribute(name); if (STRING_BOOLEAN_ATTRS.has(name)) try { return JSON.parse(value.toLowerCase()); } catch {} - if (NUMERIC_ATTRS.has(name) && value?.trim() && Number.isFinite(Number(value))) return Number(value); + if (NUMERIC_ATTRS.has(name) && value?.trim() && isFinite(value)) return +value; return value; } diff --git a/test/tests/ext/hx-live.js b/test/tests/ext/hx-live.js index 74085ff60..bff980f19 100644 --- a/test/tests/ext/hx-live.js +++ b/test/tests/ext/hx-live.js @@ -1261,6 +1261,46 @@ describe('hx-live extension', function () { 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('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('class is the typed class attribute', function() { playground().innerHTML = ''; let item = htmx.live.q('#item'); diff --git a/www/src/content/extensions/06-hx-live.md b/www/src/content/extensions/06-hx-live.md index f1ad7a7ae..68c559bd4 100644 --- a/www/src/content/extensions/06-hx-live.md +++ b/www/src/content/extensions/06-hx-live.md @@ -390,7 +390,7 @@ Use bracket notation for names that are not JavaScript identifiers, and for comp 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`) read as numbers. +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"`. From e9fcf53309a25202a34fff7a581041b11d0703de Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Fri, 14 Aug 2026 23:43:18 +0300 Subject: [PATCH 23/39] Keep empty hx-live queries composable --- src/ext/hx-live.js | 6 +++--- src/htmx.d.ts | 6 +++--- test/tests/ext/hx-live.js | 26 ++++++++++++++++++------ www/src/content/extensions/06-hx-live.md | 2 ++ www/tests/types/hx-live.ts | 8 +++++--- 5 files changed, 33 insertions(+), 15 deletions(-) diff --git a/src/ext/hx-live.js b/src/ext/hx-live.js index c28bf6801..147d0c789 100644 --- a/src/ext/hx-live.js +++ b/src/ext/hx-live.js @@ -671,11 +671,11 @@ if (p === 'take') return (name, scope) => { applyTake(elts, name, scope); return proxy; }; if (p === 'toggle') return (name, ...values) => { elts.forEach(e => applyToggle(e, name, ...values)); return proxy; }; if (p === 'attr') return (local ||= makeStateScope(elts, false)).attr; - if (p === 'data') return elts[0] ? (local ||= makeStateScope(elts, false)).data : undefined; + if (p === 'data') return (local ||= makeStateScope(elts, false)).data; if (p === 'class') return (local ||= makeStateScope(elts, false)).class; - if (p === 'closest') return elts[0] ? closest ||= makeStateScope(elts, true) : undefined; + if (p === 'closest') return closest ||= makeStateScope(elts, true); if (arrayMethods.has(p)) return elts[p].bind(elts); - if (p === 'aria') return elts[0] ? (local ||= makeStateScope(elts, false)).aria : undefined; + if (p === 'aria') return (local ||= makeStateScope(elts, false)).aria; 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])); diff --git a/src/htmx.d.ts b/src/htmx.d.ts index c68505b09..156d087d8 100644 --- a/src/htmx.d.ts +++ b/src/htmx.d.ts @@ -281,13 +281,13 @@ export namespace HxLive { /** Typed attributes on the selected elements themselves. */ readonly attr: AttrProxy; /** Typed `data-*` values on the selected elements themselves. */ - readonly data?: DataProxy; + readonly data: DataProxy; /** Typed `aria-*` values on the selected elements themselves. */ - readonly aria?: AriaProxy; + readonly aria: AriaProxy; /** Class state and `classList` methods on the selected elements themselves. */ readonly class: ClassProxy; /** Typed state on the nearest owner of each selected element. */ - readonly closest?: Scope; + 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. diff --git a/test/tests/ext/hx-live.js b/test/tests/ext/hx-live.js index bff980f19..1a34daca5 100644 --- a/test/tests/ext/hx-live.js +++ b/test/tests/ext/hx-live.js @@ -1362,12 +1362,26 @@ describe('hx-live extension', function () { classes.contains('toggle').should.equal(false); }); - it('class state is empty when q() has no matches', function() { - let classes = htmx.live.q('.missing').class; - - assert.isUndefined(classes.active); - classes.active = true; - [...classes].should.deep.equal([]); + 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('state reads typed DOM values', function() { diff --git a/www/src/content/extensions/06-hx-live.md b/www/src/content/extensions/06-hx-live.md index 68c559bd4..e77c6b0f8 100644 --- a/www/src/content/extensions/06-hx-live.md +++ b/www/src/content/extensions/06-hx-live.md @@ -298,6 +298,8 @@ Inside expressions, `this` is the element, the full htmx API is available unpref `q()` returns a proxy over a set of elements. Read from the first match, write to all. +With no matches, state reads return `undefined` and writes do nothing, including through `.closest`. + ```js q('.row') // every .row in the document q('#bar') // single element by id diff --git a/www/tests/types/hx-live.ts b/www/tests/types/hx-live.ts index d1a7914e3..4d44e623a 100644 --- a/www/tests/types/hx-live.ts +++ b/www/tests/types/hx-live.ts @@ -1,7 +1,7 @@ import htmx, { type HxLive } from '../../../src/htmx'; const live = htmx.live!; -const aria = live.q('#item').aria!; +const aria = live.q('#item').aria; const busy: boolean | undefined = aria.busy; aria.busy = true; @@ -18,11 +18,13 @@ aria.controls = 'label'; // @ts-expect-error updater results must match the attribute aria.busy = () => 'true'; -const data: HxLive.DataProxy = live.q('#item').data!; -const classes: HxLive.ClassProxy = live.q('#item').class!; +const data: HxLive.DataProxy = live.q('#item').data; +const classes: HxLive.ClassProxy = live.q('#item').class; +const closest: HxLive.Scope = live.q('#item').closest; const increment: HxLive.Updater = value => value + 1; void busy; void data; void classes; +void closest; void increment; From 2177e268bbdbfdbb39c1848121f5c7ddb6a767a1 Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Sat, 15 Aug 2026 00:11:40 +0300 Subject: [PATCH 24/39] Keep empty take queries inert --- src/ext/hx-live.js | 2 +- test/tests/ext/hx-live.js | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/ext/hx-live.js b/src/ext/hx-live.js index 147d0c789..a0caec79e 100644 --- a/src/ext/hx-live.js +++ b/src/ext/hx-live.js @@ -474,7 +474,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; diff --git a/test/tests/ext/hx-live.js b/test/tests/ext/hx-live.js index 1a34daca5..db5b2e71e 100644 --- a/test/tests/ext/hx-live.js +++ b/test/tests/ext/hx-live.js @@ -1384,6 +1384,16 @@ describe('hx-live extension', function () { playground().children.length.should.equal(0); }); + it('take does nothing when q() has no matches', function() { + playground().innerHTML = ''; + + htmx.live.q('.missing').take('.active').take('aria-selected'); + + let owner = playground().querySelector('#owner'); + owner.classList.contains('active').should.equal(true); + owner.getAttribute('aria-selected').should.equal('true'); + }); + it('state reads typed DOM values', function() { playground().innerHTML = ''; let item = htmx.live.q('#item'); From 9e5dab805322c467b9df4be6f60a6203197dc20b Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Sat, 15 Aug 2026 00:22:59 +0300 Subject: [PATCH 25/39] Cover the hx-live take shortcut --- src/skills/htmx-guidance.md | 4 ++-- test/tests/ext/hx-live.js | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/skills/htmx-guidance.md b/src/skills/htmx-guidance.md index 2d46e1cde..5323be32f 100644 --- a/src/skills/htmx-guidance.md +++ b/src/skills/htmx-guidance.md @@ -446,7 +446,7 @@ htmx.findAll(".items") // Find all matching htmx.trigger(elt, "myEvent", {detail: ...}) // Fire custom event htmx.swap(ctx) // Manual swap htmx.timeout(1000) // Promise that resolves after delay -htmx.live.take(elt, "active", ".tab") // Take class — provided by hx-live +htmx.live.take(elt, ".active", ".tab") // Take class — provided by hx-live htmx.live.forEvent(elt, "click", 5000) // Race events/timeouts — provided by hx-live htmx.live.nextFrame() // requestAnimationFrame promise — provided by hx-live ``` @@ -667,4 +667,4 @@ When generating htmx code: 1. **GET and DELETE don't include form data** -- use `hx-include="closest form"` if needed 1. When showing patterns, include both the HTML and describe what the server endpoint should return 1. There are many useful extensions, for examples sse.js (Server Sent Events) for more dynamic situation and - hx-preload.js for speeding up navigational requests. Suggest them if they make sense. \ No newline at end of file + hx-preload.js for speeding up navigational requests. Suggest them if they make sense. diff --git a/test/tests/ext/hx-live.js b/test/tests/ext/hx-live.js index db5b2e71e..dda162c29 100644 --- a/test/tests/ext/hx-live.js +++ b/test/tests/ext/hx-live.js @@ -1010,6 +1010,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, From f2411916450547d75eed2ceaae39ed59207467fe Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Sat, 15 Aug 2026 00:46:18 +0300 Subject: [PATCH 26/39] Unify hx-live class proxies --- src/ext/hx-live.js | 130 ++++++++++------------- src/htmx.d.ts | 13 ++- test/tests/ext/hx-live.js | 22 ++++ www/src/content/extensions/06-hx-live.md | 2 + www/tests/types/hx-live.ts | 3 + 5 files changed, 93 insertions(+), 77 deletions(-) diff --git a/src/ext/hx-live.js b/src/ext/hx-live.js index a0caec79e..33c7f0448 100644 --- a/src/ext/hx-live.js +++ b/src/ext/hx-live.js @@ -207,8 +207,8 @@ 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(element, name) { - return element.classList.contains(name); + function readClass(elt, name) { + return !!elt?.classList.contains(name); } function writeClass(elt, name, value) { @@ -220,48 +220,57 @@ if (!elt.classList.length) elt.removeAttribute('class'); } - let CLASS_WRITE_METHODS = new Set('add remove toggle replace'.split(' ')); - - function makeClassProxy(elts) { + function makeClassProxy(elts, cascades = false) { let first = elts[0]; - let write = (name, value) => { for (let e of elts) writeClass(e, name, value); }; + let owner = (elt, name) => cascades ? elt.closest('.' + CSS.escape(name)) : elt; + let read = name => readClass(first && owner(first, name), name); + let write = (name, value) => eachTarget(elts, elt => owner(elt, name), true, elt => writeClass(elt, name, value)); + let methods = { + assign(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + console.warn(`htmx: class.assign expects an object, got ${Array.isArray(value) ? 'array' : typeof value}.`, { elts }); + return; + } + writeClasses(write, value); + }, + 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; + }, + 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; + } + }; return new Proxy({}, { get: (_, name) => { - if (!first) return name === Symbol.iterator ? () => [][Symbol.iterator]() : undefined; - if (name === 'assign') { - return value => { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - console.warn(`htmx: class.assign expects an object, got ${Array.isArray(value) ? 'array' : typeof value}.`, { elts }); - return; - } - for (let e of elts) writeClasses(e, value); - }; + 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 (name in first.classList) { - let member = first.classList[name]; - if (typeof member !== 'function') return member; - if (typeof name !== 'string' || elts.length === 1 || !CLASS_WRITE_METHODS.has(name)) { - return member.bind(first.classList); - } - return (...args) => { - let result; - for (let i = 0; i < elts.length; i++) { - let next = elts[i].classList[name](...args); - if (i === 0) result = next; - } - return result; - }; - } - if (typeof name !== 'string') return undefined; - return readClass(first, name); + 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 (name === 'value') { - for (let elt of elts) elt.classList.value = value; - return true; - } - for (let elt of elts) writeClass(elt, name, value); + if (!cascades && name === 'value') for (let elt of elts) elt.classList.value = value; + else write(name, value); return true; }, deleteProperty: (_, name) => { @@ -269,39 +278,20 @@ write(name, false); return true; }, - has: (_, name) => typeof name === 'string' && !!first && readClass(first, name), - ownKeys: () => first ? [...first.classList] : [], - getOwnPropertyDescriptor: (_, name) => first && readClass(first, name) + has: (_, name) => typeof name === 'string' && read(name), + ownKeys: () => !cascades && first ? [...first.classList] : [], + getOwnPropertyDescriptor: (_, name) => !cascades && read(name) ? { enumerable: true, configurable: true } : undefined }); } - function makeClosestClassProxy(elts) { - let owner = (elt, name) => elt.closest('.' + CSS.escape(name)); - return new Proxy({}, { - get: (_, name) => typeof name === 'string' && !!elts[0] ? !!owner(elts[0], name) : undefined, - set: (_, name, value) => { - if (typeof name !== 'string') return false; - eachTarget(elts, elt => owner(elt, name), true, elt => { - writeClass(elt, name, value); - }); - return true; - }, - deleteProperty: (_, name) => { - if (typeof name !== 'string') return false; - eachTarget(elts, elt => owner(elt, name), false, elt => writeClass(elt, name, false)); - return true; - } - }); - } - function makeStateScope(elts, cascades) { let data, aria, classes, attr; let scope = { get data() { return data ||= makeDataProxy(elts, cascades); }, get aria() { return aria ||= makeAriaProxy(elts, cascades); }, - get class() { return classes ||= cascades ? makeClosestClassProxy(elts) : makeClassProxy(elts); }, + get class() { return classes ||= makeClassProxy(elts, cascades); }, get attr() { return attr ||= makeAttrProxy(elts, cascades, scope); } }; return scope; @@ -439,19 +429,13 @@ } } - function writeClasses(elt, value) { + function writeClasses(write, value) { let written = []; - if (typeof value === 'string') { - for (let c of value.trim().split(/\s+/).filter(Boolean)) { - written.push(c); - writeClass(elt, c, true); - } - } else if (value && typeof value === 'object') { - for (let [key, cond] of Object.entries(value)) { - for (let c of key.trim().split(/\s+/).filter(Boolean)) { - written.push(c); - writeClass(elt, c, !!cond); - } + 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); } } return written; @@ -460,7 +444,7 @@ function applyMultiClass(elt, value) { let prop = api.htmxProp(elt); let oldManaged = prop.liveClasses || new Set(); - let newManaged = new Set(writeClasses(elt, value)); + 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; } diff --git a/src/htmx.d.ts b/src/htmx.d.ts index 156d087d8..99bc7cb9e 100644 --- a/src/htmx.d.ts +++ b/src/htmx.d.ts @@ -169,9 +169,14 @@ export interface HtmxSwapContext { } export namespace HxLive { - /** A `DOMTokenList` with boolean class membership and grouped assignment. */ - export interface ClassProxy extends DOMTokenList { + /** 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; } @@ -247,7 +252,7 @@ export namespace HxLive { } export interface AttrProxy { - readonly class: ClassProxy; + readonly class: ClassProxy & DOMTokenList; [name: string]: any; } @@ -285,7 +290,7 @@ export namespace HxLive { /** Typed `aria-*` values on the selected elements themselves. */ readonly aria: AriaProxy; /** Class state and `classList` methods on the selected elements themselves. */ - readonly class: ClassProxy; + readonly class: ClassProxy & DOMTokenList; /** Typed state on the nearest owner of each selected element. */ readonly closest: Scope; /** diff --git a/test/tests/ext/hx-live.js b/test/tests/ext/hx-live.js index dda162c29..964a0d64f 100644 --- a/test/tests/ext/hx-live.js +++ b/test/tests/ext/hx-live.js @@ -1179,6 +1179,28 @@ describe('hx-live extension', function () { item.closest.class.active.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 owner exists', function() { playground().innerHTML = ''; let closest = htmx.live.q('#item').closest; diff --git a/www/src/content/extensions/06-hx-live.md b/www/src/content/extensions/06-hx-live.md index e77c6b0f8..5e4229a3a 100644 --- a/www/src/content/extensions/06-hx-live.md +++ b/www/src/content/extensions/06-hx-live.md @@ -477,6 +477,8 @@ class.assign({...}) // group add/remove by truthiness 'x' in class // membership ``` +`closest.class` supports `assign`, `add`, `remove`, `toggle`, `replace`, and `contains`. Aggregate list properties such as `value`, `length`, and iteration remain local because closest ownership resolves separately for each class. + 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. diff --git a/www/tests/types/hx-live.ts b/www/tests/types/hx-live.ts index 4d44e623a..c34746dfa 100644 --- a/www/tests/types/hx-live.ts +++ b/www/tests/types/hx-live.ts @@ -21,10 +21,13 @@ aria.busy = () => 'true'; const data: HxLive.DataProxy = live.q('#item').data; const classes: HxLive.ClassProxy = live.q('#item').class; const closest: HxLive.Scope = live.q('#item').closest; +const classList: DOMTokenList = live.q('#item').class; +closest.class.toggle('active'); const increment: HxLive.Updater = value => value + 1; void busy; void data; void classes; void closest; +void classList; void increment; From 243345df5f4b7a4d1ddd7bf5e0ca238be090d0cf Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Sat, 15 Aug 2026 00:48:21 +0300 Subject: [PATCH 27/39] Compact hx-live array method lookup --- src/ext/hx-live.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/ext/hx-live.js b/src/ext/hx-live.js index 33c7f0448..607bd7126 100644 --- a/src/ext/hx-live.js +++ b/src/ext/hx-live.js @@ -632,9 +632,7 @@ }; } - 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' }; @@ -658,7 +656,7 @@ if (p === 'data') return (local ||= makeStateScope(elts, false)).data; if (p === 'class') return (local ||= makeStateScope(elts, false)).class; if (p === 'closest') return closest ||= makeStateScope(elts, true); - if (arrayMethods.has(p)) return elts[p].bind(elts); + if (arrayMethods.includes(p)) return elts[p].bind(elts); if (p === 'aria') return (local ||= makeStateScope(elts, false)).aria; let v = elts[0]?.[p]; if (typeof v === 'function') return (...a) => elts.map(e => e[p](...a))[0]; From 96eaff4a9524c8e1f9f32ad646e0c040a3d540e4 Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Sat, 15 Aug 2026 00:52:44 +0300 Subject: [PATCH 28/39] Remove data attributes through attr delete --- src/ext/hx-live.js | 2 +- test/tests/ext/hx-live.js | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/ext/hx-live.js b/src/ext/hx-live.js index 607bd7126..7a0fdee44 100644 --- a/src/ext/hx-live.js +++ b/src/ext/hx-live.js @@ -143,7 +143,7 @@ }, deleteProperty: (_, name) => { if (typeof name !== 'string') return false; - eachTarget(elts, elt => findOwner(elt, name), false, elt => writeAttr(elt, name, null)); + eachTarget(elts, elt => findOwner(elt, name), false, elt => writeAttr(elt, name, undefined)); return true; } }); diff --git a/test/tests/ext/hx-live.js b/test/tests/ext/hx-live.js index 964a0d64f..756dea7f4 100644 --- a/test/tests/ext/hx-live.js +++ b/test/tests/ext/hx-live.js @@ -1265,6 +1265,25 @@ describe('hx-live extension', function () { 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; From 203c665505322172d9f3c2e5d16adcd7d1f332cf Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Sat, 15 Aug 2026 00:54:49 +0300 Subject: [PATCH 29/39] Preserve typed toggle values --- src/ext/hx-live.js | 20 ++++++++------------ src/htmx.d.ts | 4 ++-- test/tests/ext/hx-live.js | 14 ++++++++++++++ www/src/content/extensions/06-hx-live.md | 2 ++ www/tests/types/hx-live.ts | 1 + 5 files changed, 27 insertions(+), 14 deletions(-) diff --git a/src/ext/hx-live.js b/src/ext/hx-live.js index 7a0fdee44..0bb596116 100644 --- a/src/ext/hx-live.js +++ b/src/ext/hx-live.js @@ -521,11 +521,9 @@ let key = isClass ? name.slice(1) : name; let isAria = name.startsWith('aria-'); let list = values.length > 1 ? values : values[0]; - let asArray = list && (typeof list === 'string' - ? list.split('|').map(v => v.trim()) - : list); + 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); @@ -536,16 +534,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); } } diff --git a/src/htmx.d.ts b/src/htmx.d.ts index 99bc7cb9e..e23e46b61 100644 --- a/src/htmx.d.ts +++ b/src/htmx.d.ts @@ -302,7 +302,7 @@ export namespace HxLive { * 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[])[]): Query; + toggle(name: string, ...values: any[]): Query; /** * Dispatch a `CustomEvent` from all matched elements. * @param bubbles - Defaults to `true`. @@ -347,7 +347,7 @@ export interface HxLive { /** 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; + 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 diff --git a/test/tests/ext/hx-live.js b/test/tests/ext/hx-live.js index 756dea7f4..08a429184 100644 --- a/test/tests/ext/hx-live.js +++ b/test/tests/ext/hx-live.js @@ -1637,6 +1637,20 @@ describe('hx-live extension', function () { 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 = ''; htmx.process(playground()); diff --git a/www/src/content/extensions/06-hx-live.md b/www/src/content/extensions/06-hx-live.md index 5e4229a3a..44b22a085 100644 --- a/www/src/content/extensions/06-hx-live.md +++ b/www/src/content/extensions/06-hx-live.md @@ -423,6 +423,8 @@ toggle('data-view', 'grid|list|table') toggle('data-view', ['grid', 'list', 'table']) ``` +Cycle values keep their types. For example, `'1'` remains a string while `1` remains a number. + ### `take(name, scope?)` Move a class or attribute from siblings to this element. Pass a `scope` selector to widen or restrict the source set. diff --git a/www/tests/types/hx-live.ts b/www/tests/types/hx-live.ts index c34746dfa..e0c608614 100644 --- a/www/tests/types/hx-live.ts +++ b/www/tests/types/hx-live.ts @@ -23,6 +23,7 @@ const classes: HxLive.ClassProxy = live.q('#item').class; const closest: HxLive.Scope = live.q('#item').closest; const classList: DOMTokenList = live.q('#item').class; closest.class.toggle('active'); +live.q('#item').toggle('data-count', 1, 2); const increment: HxLive.Updater = value => value + 1; void busy; From b0457833b778b3cd06808dba64764001d9cb89d4 Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Sat, 15 Aug 2026 01:01:25 +0300 Subject: [PATCH 30/39] Preserve native hidden state --- src/ext/hx-live.js | 4 +-- test/tests/ext/hx-live.js | 32 ++++++++++++++++++++++++ www/src/content/extensions/06-hx-live.md | 2 +- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/ext/hx-live.js b/src/ext/hx-live.js index 0bb596116..054d06580 100644 --- a/src/ext/hx-live.js +++ b/src/ext/hx-live.js @@ -69,8 +69,8 @@ }); } - 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 checked selected'.split(' ')); - let PROPERTY_BINDING_ATTRS = new Set(['checked','value','selected']); + 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 checked selected'.split(' ')); + let PROPERTY_BINDING_ATTRS = new Set(['checked','value','selected','hidden']); let STRING_BOOLEAN_ATTRS = new Set(['contenteditable','draggable','spellcheck','writingsuggestions']); let NUMERIC_ATTRS = new Set('tabindex colspan rowspan maxlength minlength size span start rows cols width height min max step low high optimum'.split(' ')); diff --git a/test/tests/ext/hx-live.js b/test/tests/ext/hx-live.js index 08a429184..4555dc7ad 100644 --- a/test/tests/ext/hx-live.js +++ b/test/tests/ext/hx-live.js @@ -1302,6 +1302,38 @@ describe('hx-live extension', function () { element.hasAttribute('writingsuggestions').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('generic attributes stringify booleans and remove null', function() { playground().innerHTML = '
      '; let attr = htmx.live.q('#item').attr; diff --git a/www/src/content/extensions/06-hx-live.md b/www/src/content/extensions/06-hx-live.md index 44b22a085..48b56e88f 100644 --- a/www/src/content/extensions/06-hx-live.md +++ b/www/src/content/extensions/06-hx-live.md @@ -1027,7 +1027,7 @@ When an `hx-live` element is removed, its expression drops out on the next sched
      ``` -**Property-backed attributes** (`checked`, `value`, `selected`). Sync both the DOM property and the HTML attribute. +**Property-backed attributes** (`checked`, `value`, `selected`, `hidden`). Sync both the DOM property and the HTML attribute. `hidden` preserves the `"until-found"` state. ```html From 3f66d237d0dd3fb86cfe860cabab22fc09a4671a Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Sat, 15 Aug 2026 01:15:17 +0300 Subject: [PATCH 31/39] Cover current HTML boolean attributes --- src/ext/hx-live.js | 15 +++++-------- test/tests/ext/hx-live.js | 28 ++++++++++++++++++++++++ www/src/content/extensions/06-hx-live.md | 4 +--- 3 files changed, 34 insertions(+), 13 deletions(-) diff --git a/src/ext/hx-live.js b/src/ext/hx-live.js index 054d06580..be2e26754 100644 --- a/src/ext/hx-live.js +++ b/src/ext/hx-live.js @@ -69,7 +69,7 @@ }); } - 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 checked selected'.split(' ')); + 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 checked selected alpha headingreset'.split(' ')); let PROPERTY_BINDING_ATTRS = new Set(['checked','value','selected','hidden']); let STRING_BOOLEAN_ATTRS = new Set(['contenteditable','draggable','spellcheck','writingsuggestions']); let NUMERIC_ATTRS = new Set('tabindex colspan rowspan maxlength minlength size span start rows cols width height min max step low high optimum'.split(' ')); @@ -86,7 +86,7 @@ return element.value === '' ? null : element.valueAsNumber; } if (PROPERTY_BINDING_ATTRS.has(name)) return element[name]; - if (BOOLEAN_ATTRS.has(name)) return element.hasAttribute(name); + if (BOOLEAN_ATTRS.has(name) || name.startsWith('shadowroot') && name !== 'shadowrootmode' && name !== 'shadowrootslotassignment') return element.hasAttribute(name); let value = element.getAttribute(name); if (STRING_BOOLEAN_ATTRS.has(name)) try { return JSON.parse(value.toLowerCase()); } catch {} if (NUMERIC_ATTRS.has(name) && value?.trim() && isFinite(value)) return +value; @@ -105,7 +105,7 @@ writeData(element, name, value); } else if (PROPERTY_BINDING_ATTRS.has(name)) { applyPropertyBinding(element, name, value); - } else if (BOOLEAN_ATTRS.has(name)) { + } else if (BOOLEAN_ATTRS.has(name) || name.startsWith('shadowroot') && name !== 'shadowrootmode' && name !== 'shadowrootslotassignment') { element.toggleAttribute(name, !!value); } else if (value === null || value === undefined) { element.removeAttribute(name); @@ -810,17 +810,12 @@ 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(e, name, ...values)), + 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)) }; diff --git a/test/tests/ext/hx-live.js b/test/tests/ext/hx-live.js index 4555dc7ad..36bec5d1d 100644 --- a/test/tests/ext/hx-live.js +++ b/test/tests/ext/hx-live.js @@ -1334,6 +1334,34 @@ describe('hx-live extension', function () { playground().querySelector('#hidden').getAttribute('hidden').should.equal('until-found'); }); + 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; diff --git a/www/src/content/extensions/06-hx-live.md b/www/src/content/extensions/06-hx-live.md index 48b56e88f..1106cbbe9 100644 --- a/www/src/content/extensions/06-hx-live.md +++ b/www/src/content/extensions/06-hx-live.md @@ -994,13 +994,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. This includes current declarative shadow-root boolean attributes. ```html ``` -Non-object arguments warn and do nothing. - `class` extends the native [`DOMTokenList`](https://developer.mozilla.org/en-US/docs/Web/API/DOMTokenList): ```js @@ -831,6 +829,7 @@ 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 ``` Typical use: wait for a CSS transition to finish, with a safety timeout. @@ -958,17 +957,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 @@ -994,7 +993,7 @@ 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`, `required`, `open`, `readonly`, `inert`, ...). Truthy adds the attribute; falsy removes it. This includes current declarative shadow-root boolean attributes. +**Boolean attributes** (`disabled`, `required`, `open`, `readonly`, `inert`, ...). Truthy adds the attribute; falsy removes it. ```html `); + + button.click(); + + window.__bareClassMembers.should.deep.equal([true, true, true, true]); + button.classList.contains('ready').should.equal(true); + delete window.__bareClassMembers; + }); + + 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('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.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('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'); diff --git a/www/src/content/extensions/06-hx-live.md b/www/src/content/extensions/06-hx-live.md index e6457904d..534bc66c2 100644 --- a/www/src/content/extensions/06-hx-live.md +++ b/www/src/content/extensions/06-hx-live.md @@ -472,9 +472,15 @@ class.replace('a', 'b') // replace one class with another class.contains('x') // membership class.value // complete class attribute class.length // number of classes -[...class] // class names class.assign({...}) // group add/remove by truthiness -'x' in class // membership +``` + +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 closest ownership resolves separately for each class. From 28c988b2539544f99eadc226c20a54d398d322a4 Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Sun, 16 Aug 2026 22:39:09 +0300 Subject: [PATCH 39/39] Improve hx-live hot paths --- src/ext/hx-live.js | 169 ++++++++++++----------- src/htmx.d.ts | 2 +- test/tests/ext/hx-live.js | 12 +- www/src/content/extensions/06-hx-live.md | 20 +-- 4 files changed, 104 insertions(+), 99 deletions(-) diff --git a/src/ext/hx-live.js b/src/ext/hx-live.js index e33720358..27476ef95 100644 --- a/src/ext/hx-live.js +++ b/src/ext/hx-live.js @@ -69,9 +69,9 @@ }); } - 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 checked selected alpha headingreset'.split(' ')); - let PROPERTY_BINDING_ATTRS = new Set(['checked','value','selected','hidden']); - let STRING_BOOLEAN_ATTRS = new Set(['contenteditable','draggable','spellcheck','writingsuggestions']); + 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(' ')); function normalizeAttrName(elt, name) { @@ -88,7 +88,7 @@ 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 (STRING_BOOLEAN_ATTRS.has(name)) try { return JSON.parse(value.toLowerCase()); } catch {} + 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; } @@ -107,69 +107,69 @@ 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 || value === undefined) { + } else if (value == null) { element.removeAttribute(name); } else { element.setAttribute(name, String(value)); } } - function eachTarget(elts, findOwner, fallback, fn) { - let targets = new Set(); - for (let elt of elts) { - let target = findOwner(elt) || (fallback ? elt : null); - if (target) targets.add(target); - } - for (let target of targets) fn(target); + function writeTargets(elts, target) { + return target ? new Set(elts.map(target)) : elts; } - function makeAttrProxy(elts, cascades, scope, prefix = '') { - let attrName = prop => prefix + (prefix === 'data-' ? camelToKebab(prop) : prefix ? prop.toLowerCase() : prop); - let findOwner = (elt, name) => cascades - ? elt.closest('[' + CSS.escape(normalizeAttrName(elt, name)) + ']') - : prefix ? elt.hasAttribute(name) ? elt : null : elt; - return new Proxy({}, { - get: (_, prop) => { - if (!prefix && prop === 'class') return scope.class; - if (typeof prop !== 'string') return undefined; - let name = attrName(prop); - let owner = elts[0] && findOwner(elts[0], name); - return owner ? readAttr(owner, name) : undefined; - }, - set: (_, prop, value) => { - if (typeof prop !== 'string') return false; - let name = attrName(prop); - eachTarget(elts, elt => findOwner(elt, name), true, elt => { - writeAttr(elt, name, value); - }); - return true; - }, - deleteProperty: (_, prop) => { - if (typeof prop !== 'string') return false; - let name = attrName(prop); - eachTarget(elts, elt => findOwner(elt, name), false, elt => writeAttr(elt, name, undefined)); - return true; - }, - has: (_, prop) => { - if (prefix !== 'data-' || typeof prop !== 'string') return false; - return !!elts[0] && !!findOwner(elts[0], attrName(prop)); - }, - ownKeys: () => { - if (prefix !== 'data-') return []; - let result = [], seen = new Set(); - for (let node = elts[0]; node; node = cascades ? node.parentElement : null) { - for (let key of Object.keys(node.dataset)) if (key !== 'htmxPowered' && !seen.has(key)) { - seen.add(key); - result.push(key); - } + 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); } - return result; - }, - getOwnPropertyDescriptor: (_, prop) => { - if (prefix !== 'data-' || typeof prop !== 'string' || prop === 'htmxPowered') return; - if (elts[0] && findOwner(elts[0], attrName(prop))) return { enumerable: true, configurable: true }; } - }); + 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) { @@ -245,9 +245,10 @@ function makeClassProxy(elts, cascades = false) { let first = elts[0]; - let owner = (elt, name) => cascades ? elt.closest('.' + CSS.escape(name)) : elt; - let read = name => readClass(first && owner(first, name), name); - let write = (name, value) => eachTarget(elts, elt => owner(elt, name), true, elt => writeClass(elt, name, value)); + 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)) { @@ -320,6 +321,27 @@ return scope; } + 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); @@ -582,12 +604,11 @@ 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(e, name, ...values)); return proxy; }; - if (p === 'attr') return (local ||= makeStateScope(elts, false)).attr; - if (p === 'data') return (local ||= makeStateScope(elts, false)).data; - if (p === 'class') return (local ||= makeStateScope(elts, false)).class; + 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); - if (p === 'aria') return (local ||= makeStateScope(elts, false)).aria; 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])); @@ -766,24 +787,8 @@ if (--swaps === 0 && fns.size > 0) schedule(); }, htmx_scope: (elt, detail) => { - let local = makeStateScope([elt], false); - let closest = makeStateScope([elt], true); - 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(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 - }); + 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 0abc5bc09..63e5cf294 100644 --- a/src/htmx.d.ts +++ b/src/htmx.d.ts @@ -291,7 +291,7 @@ export namespace HxLive { readonly aria: AriaProxy; /** Class state and `classList` methods on the selected elements themselves. */ readonly class: ClassProxy & DOMTokenList; - /** Typed state on the nearest owner of each selected element. */ + /** 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. diff --git a/test/tests/ext/hx-live.js b/test/tests/ext/hx-live.js index 88d9f3c61..c31461619 100644 --- a/test/tests/ext/hx-live.js +++ b/test/tests/ext/hx-live.js @@ -1344,7 +1344,7 @@ describe('hx-live extension', function () { item.parentElement.parentElement.hasAttribute('class').should.equal(false); }); - it('closest writes to the current element when no owner exists', function() { + it('closest writes to the current element when no match exists', function() { playground().innerHTML = ''; let closest = htmx.live.q('#item').closest; @@ -1358,7 +1358,7 @@ describe('hx-live extension', function () { ); }); - it('closest updates each shared owner once', function() { + it('closest updates each shared match once', function() { playground().innerHTML = `
      ``` -Use `closest.aria.*` when you explicitly want the nearest owner. A write with -no owner adds the state to the current element: +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 @@ -543,7 +543,7 @@ Use `toggle()` and `take()` for transitions: ``` -`toggle()` flips boolean ARIA between `"true"` and `"false"`. `take()` writes `"false"` on sibling owners, then `"true"` on this owner. +`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: