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 = ` +