Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions src/ext/hx-live.js
Original file line number Diff line number Diff line change
Expand Up @@ -195,32 +195,41 @@
// `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; }
},
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);
Expand All @@ -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 };
}
});
}
Expand Down Expand Up @@ -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];
Expand Down
132 changes: 132 additions & 0 deletions test/tests/ext/hx-live.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<div id="state"></div>';
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 = '<div id="state"></div>';
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 = `
<section data-count="1">
<form id="form" data-ready="false"></form>
</section>
`;
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 = `
<section data-count="1">
<button hx-on:click="
window.__dataState = [q(this).data.count, data.count];
await timeout(5);
q(this).data.count = 2
">change</button>
</section>
`;
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 = `
<object data="/chart.svg" hx-on:click="q(this).data.ready = true"></object>
`;
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 = `
<section data-state="active">
<button hx-on:click="delete data.state">clear</button>
</section>
`;
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 = `
<div id="me" data-foo="local"
Expand Down
30 changes: 23 additions & 7 deletions www/src/content/extensions/06-hx-live.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ take('.active') // implicit scope: parent element's subtr

### `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
<div data-size="medium">
Expand All @@ -285,9 +285,17 @@ Read or write `data-*` attributes on the closest ancestor that has them. Lets co
</div>
```

`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
<div data-count="1" data-active="false" data-cart="[]">
Expand Down Expand Up @@ -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 `:<attr>` works on `data-*`, you can also store derived values in the DOM:

Expand Down
Loading