diff --git a/package.json b/package.json index 8b05f62..54ba9fd 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,7 @@ "size-limit": [ { "path": "./dist/WebComponent.js", - "limit": "1.35 KB" + "limit": "1.4 KB" }, { "path": "./dist/html.js", diff --git a/src/utils/deserialize.mjs b/src/utils/deserialize.mjs index d0b430a..025af48 100644 --- a/src/utils/deserialize.mjs +++ b/src/utils/deserialize.mjs @@ -5,8 +5,11 @@ */ export function deserialize(value, type) { switch (type) { - case 'number': case 'boolean': + // bare presence follows the HTML convention: `` / `flag=""` is true. + if (value === '') return true + // falls through + case 'number': case 'object': case 'undefined': return JSON.parse(value) diff --git a/test/WebComponent.test.mjs b/test/WebComponent.test.mjs index 378886e..7923a0f 100644 --- a/test/WebComponent.test.mjs +++ b/test/WebComponent.test.mjs @@ -35,6 +35,11 @@ describe('WebComponent', () => { * and returns it. Remove it with `el.remove()` to trigger disconnect. */ let tagSeq = 0 +/** + * + * @param Class + * @param attributes + */ function mount(Class, attributes = {}) { const tag = `wc-test-${tagSeq++}` window.customElements.define(tag, Class) @@ -265,6 +270,38 @@ describe('reactive props (documented contract)', () => { expect(el.props.count).toBe(5) }) + it('turns a boolean prop on from a bare attribute ()', () => { + const el = connected() + // `` and `` are the same to the DOM + el.setAttribute('active', '') + expect(el.props.active).toBe(true) + }) + + it('honors an explicit "true"/"false" boolean attribute value', () => { + const el = connected() + el.setAttribute('active', 'true') + expect(el.props.active).toBe(true) + el.setAttribute('active', 'false') + expect(el.props.active).toBe(false) + }) + + it('resets a boolean prop to its default when the attribute is removed', () => { + const el = connected() + el.setAttribute('active', '') + el.removeAttribute('active') + expect(el.props.active).toBe(false) + }) + + it('round-trips a boolean prop write through its reflected attribute', () => { + const el = connected() + el.props.active = true + expect(el.getAttribute('active')).toBe('true') + expect(el.props.active).toBe(true) + el.props.active = false + expect(el.getAttribute('active')).toBe('false') + expect(el.props.active).toBe(false) + }) + it('fires onChanges with property/attribute/previousValue/currentValue', () => { const changes = [] class WithChanges extends WebComponent { @@ -475,7 +512,10 @@ describe('attribute value & removal handling', () => { expect(el.props.label).toBe('') // regression: must NOT echo back as "true" expect(el.getAttribute('label')).toBe('') - expect(changes.at(-1)).toMatchObject({ property: 'label', currentValue: '' }) + expect(changes.at(-1)).toMatchObject({ + property: 'label', + currentValue: '', + }) }) it('resets a prop to its static default when the attribute is removed', () => { @@ -518,7 +558,10 @@ describe('attribute value & removal handling', () => { // malformed value must not throw and must still fire onChanges/render expect(() => el.setAttribute('count', 'abc')).not.toThrow() - expect(changes.at(-1)).toMatchObject({ property: 'count', currentValue: 'abc' }) + expect(changes.at(-1)).toMatchObject({ + property: 'count', + currentValue: 'abc', + }) error.mockRestore() }) diff --git a/test/utils/deserialize.test.mjs b/test/utils/deserialize.test.mjs index 28aca12..8f9798f 100644 --- a/test/utils/deserialize.test.mjs +++ b/test/utils/deserialize.test.mjs @@ -13,8 +13,14 @@ describe('deserialize', () => { expect(deserialize('false', 'boolean')).toBe(false) }) + test('treats a bare/empty boolean attribute as true', () => { + expect(deserialize('', 'boolean')).toBe(true) + }) + test('parses object strings', () => { - expect(deserialize('{"hello":"world"}', 'object')).toEqual({ hello: 'world' }) + expect(deserialize('{"hello":"world"}', 'object')).toEqual({ + hello: 'world', + }) expect(deserialize('[1,2,3]', 'object')).toEqual([1, 2, 3]) expect(deserialize('null', 'object')).toBeNull() })