diff --git a/src/ext/hx-live.js b/src/ext/hx-live.js index fc58c4e9c..b39a83a18 100644 --- a/src/ext/hx-live.js +++ b/src/ext/hx-live.js @@ -195,12 +195,15 @@ // `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(elt, cascades = true) { + let findOwner = 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 = findOwner(kebab); if (!ancestor) return undefined; let raw = ancestor.dataset[prop]; try { return JSON.parse(raw); } catch { return raw; } @@ -208,19 +211,25 @@ set: (_, prop, val) => { if (typeof prop !== 'string') return false; let kebab = camelToKebab(prop); - let target = elt.closest('[data-' + kebab + ']') || elt; + let target = findOwner(kebab) || elt; target.dataset[prop] = typeof val === 'string' ? val : JSON.stringify(val); return true; }, + deleteProperty: (_, prop) => { + if (typeof prop !== 'string') return false; + let kebab = camelToKebab(prop); + findOwner(kebab)?.removeAttribute('data-' + kebab); + return true; + }, has: (_, prop) => { if (typeof prop !== 'string') return false; let kebab = camelToKebab(prop); - return !!elt.closest('[data-' + kebab + ']'); + return !!findOwner(kebab); }, ownKeys: () => { let result = []; let seen = new Set(); - for (let node = elt; node; node = node.parentElement) { + for (let node = elt; node; node = cascades ? node.parentElement : null) { for (let key of Object.keys(node.dataset)) { if (key !== 'htmxPowered' && !seen.has(key)) { seen.add(key); @@ -233,7 +242,7 @@ getOwnPropertyDescriptor: (_, prop) => { if (typeof prop !== 'string' || prop === 'htmxPowered') return; let kebab = camelToKebab(prop); - if (elt.closest('[data-' + kebab + ']')) return { enumerable: true, configurable: true }; + if (findOwner(kebab)) return { enumerable: true, configurable: true }; } }); } @@ -468,7 +477,7 @@ applyAttr(elts, name, ...rest); return proxy; }; - if (p === 'data') return elts[0] ? makeDataProxy(elts[0]) : undefined; + if (p === 'data') return elts[0] ? makeDataProxy(elts[0], false) : undefined; if (arrayMethods.has(p)) return elts[p].bind(elts); let v = elts[0]?.[p]; if (typeof v === 'function') return (...a) => elts.map(e => e[p](...a))[0]; diff --git a/test/tests/ext/hx-live.js b/test/tests/ext/hx-live.js index 6c00b2062..7f85c0db6 100644 --- a/test/tests/ext/hx-live.js +++ b/test/tests/ext/hx-live.js @@ -1302,6 +1302,138 @@ describe('hx-live extension', function () { // cascading data proxy // ------------------------------------------------------------------------- + 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 = `
@@ -285,9 +285,17 @@ 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. +Use bare `data` for shared state. Use `q()` for one element: -Values are automatically JSON-serialized on write and parsed on read. Booleans, numbers, arrays, and objects round-trip transparently: +```js +data.count // closest data-count, starting at this +q(this).data.count // data-count on this +q('#cart').data.count // data-count on the selected cart +``` + +`data.*` checks the current element, then each ancestor. `q(...).data` checks the selected element only. + +On write, hx-live converts booleans, numbers, arrays, and objects to JSON. On read, it converts the JSON back to JavaScript values: ```html
@@ -316,14 +324,22 @@ 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 state 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 +delete data.count // remove the closest data-count +delete q(this).data.count // remove data-count from this +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"`. Use `delete` to remove the 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: