diff --git a/docs/api.md b/docs/api.md index ffecbfa..5f7076f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -10,7 +10,7 @@ - `options.props` - Array of camelCasedProps to watch as String values or { [camelCasedProps]: "string" | "number" | "boolean" | "function" | "json" } - When specifying Array or Object as the type, the string passed into the attribute must pass `JSON.parse()` requirements. - - When specifying Boolean as the type, "true", "1", "yes", "TRUE", and "t" are mapped to `true`. All strings NOT begining with t, T, 1, y, or Y will be `false`. + - When specifying Boolean as the type, "true", "1"…"9", "yes", "TRUE", and "t", as well as absence of a value, the empty string, and value equal to the name of attribute are mapped to `true`. All strings NOT begining with t, T, 1…9, y, or Y but for the name of attribute will be `false`. - When specifying Function as the type, the string passed into the attribute must be the name of a function on `window` (or `global`). The `this` context of the function will be the instance of the WebComponent / HTMLElement when called. - If PropTypes are defined on the React component, the `options.props` will be ignored and the PropTypes will be used instead. However, we strongly recommend using `options.props` instead of PropTypes as it is usually not a good idea to use PropTypes in production. @@ -127,7 +127,11 @@ customElements.define( numProp: "number", floatProp: "number", trueProp: "boolean", + htmlTruePropPresent: "boolean", + htmlTruePropEmpty: "boolean", + htmlTruePropSame: "boolean", falseProp: "boolean", + htmlFalsePropAbsent: "boolean", arrayProp: "json", objProp: "json", }, @@ -140,10 +144,14 @@ document.body.innerHTML = ` num-prop="360" float-prop="0.5" true-prop="true" + html-true-prop-present + html-true-prop-empty="" + html-true-prop-same="html-true-prop-same" false-prop="false" array-prop='[true, 100.25, "👽", { "aliens": "welcome" }]' obj-prop='{ "very": "object", "such": "wow!" }' > + ` /* @@ -153,7 +161,11 @@ document.body.innerHTML = ` numProp: 360, floatProp: 0.5, trueProp: true, + htmlTruePropPresent: true, + htmlTruePropEmpty: true, + htmlTruePropSame: true, falseProp: false, + htmlFalsePropAbsent: false, arrayProp: [true, 100.25, "👽", { aliens: "welcome" }], objProp: { very: "object", such: "wow!" }, } diff --git a/packages/core/src/core.test.tsx b/packages/core/src/core.test.tsx index 0705737..23ec75a 100644 --- a/packages/core/src/core.test.tsx +++ b/packages/core/src/core.test.tsx @@ -109,6 +109,7 @@ describe("core", () => { text: string numProp: number boolProp: boolean + htmlBoolProp: boolean arrProp: string[] objProp: { [key: string]: string } funcProp: () => void @@ -118,6 +119,7 @@ describe("core", () => { text, numProp, boolProp, + htmlBoolProp, arrProp, objProp, funcProp, @@ -132,6 +134,7 @@ describe("core", () => { text: "string", numProp: "number", boolProp: "boolean", + htmlBoolProp: "boolean", arrProp: "json", objProp: "json", funcProp: "function", @@ -154,7 +157,7 @@ describe("core", () => { customElements.define("test-button-element-property", ButtonElement) const body = document.body - body.innerHTML = ` + body.innerHTML = ` ` const element = body.querySelector( @@ -166,6 +169,7 @@ describe("core", () => { expect(element.text).toBe("hello") expect(element.numProp).toBe(240) expect(element.boolProp).toBe(true) + expect(element.htmlBoolProp).toBe(true) expect(element.arrProp).toEqual(["hello", "world"]) expect(element.objProp).toEqual({ greeting: "hello, world" }) expect(element.funcProp).toBeInstanceOf(Function) @@ -174,6 +178,7 @@ describe("core", () => { element.text = "world" element.numProp = 100 element.boolProp = false + element.htmlBoolProp = false //@ts-ignore element.funcProp = global.newFunc @@ -181,7 +186,8 @@ describe("core", () => { expect(element.getAttribute("text")).toBe("world") expect(element.getAttribute("num-prop")).toBe("100") - expect(element.getAttribute("bool-prop")).toBe("false") + expect(element).not.toHaveAttribute("bool-prop") + expect(element).not.toHaveAttribute("html-bool-prop") expect(element.getAttribute("func-prop")).toBe("newFunc") }) diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index 9d40744..97944d4 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -95,7 +95,10 @@ export default function r2wc( const type = propTypes[prop] const transform = type ? transforms[type] : null - if (transform?.parse && value) { + if (!value && type === "boolean") { + //@ts-ignore + this[propsSymbol][prop] = this.hasAttribute(attribute) + } else if (transform?.parse && value) { //@ts-ignore this[propsSymbol][prop] = transform.parse(value, attribute, this) } @@ -125,7 +128,12 @@ export default function r2wc( const type = propTypes[prop] const transform = type ? transforms[type] : null - if (prop in propTypes && transform?.parse && value) { + if (!value && type === "boolean") { + //@ts-ignore + this[propsSymbol][prop] = this.hasAttribute(attribute) + + this[renderSymbol]() + } else if (prop in propTypes && transform?.parse && value) { //@ts-ignore this[propsSymbol][prop] = transform.parse(value, attribute, this) @@ -159,10 +167,14 @@ export default function r2wc( return this[propsSymbol][prop] }, set(value) { + const oldValue = this[propsSymbol][prop] + const transform = type ? transforms[type] : null + this[propsSymbol][prop] = value - const transform = type ? transforms[type] : null - if (transform?.stringify) { + if (type === "boolean" && !value && oldValue) { + this.removeAttribute(attribute) + } else if (transform?.stringify) { //@ts-ignore const attributeValue = transform.stringify(value, attribute, this) const oldAttributeValue = this.getAttribute(attribute) diff --git a/packages/core/src/transforms/boolean.ts b/packages/core/src/transforms/boolean.ts index 10f4ba7..e7fb863 100644 --- a/packages/core/src/transforms/boolean.ts +++ b/packages/core/src/transforms/boolean.ts @@ -2,7 +2,7 @@ import type { Transform } from "./index" const boolean: Transform = { stringify: (value) => (value ? "true" : "false"), - parse: (value) => /^[ty1-9]/i.test(value), + parse: (value, attribute) => value === attribute || /^[ty1-9]/i.test(value), } export default boolean diff --git a/packages/react-to-web-component/src/react-to-web-component.test.tsx b/packages/react-to-web-component/src/react-to-web-component.test.tsx index ce99f4e..d2d2b95 100644 --- a/packages/react-to-web-component/src/react-to-web-component.test.tsx +++ b/packages/react-to-web-component/src/react-to-web-component.test.tsx @@ -176,14 +176,20 @@ describe("react-to-web-component 1", () => { }) it("options.props can specify and will convert the String attribute value into Number, Boolean, Array, and/or Object", async () => { - expect.assertions(12) + expect.assertions(18) type CastinProps = { stringProp: string numProp: number floatProp: number - trueProp: boolean - falseProp: boolean + truePropWithValueTrue: boolean + truePropWithValueYes: boolean + truePropWithValueOne: boolean + truePropWithValueFive: boolean + truePropWithValueNine: boolean + falsePropWithValueFalse: boolean + falsePropWithValueNo: boolean + falsePropWithValueZero: boolean arrayProp: any[] objProp: object } @@ -194,8 +200,14 @@ describe("react-to-web-component 1", () => { stringProp, numProp, floatProp, - trueProp, - falseProp, + truePropWithValueTrue, + truePropWithValueYes, + truePropWithValueOne, + truePropWithValueFive, + truePropWithValueNine, + falsePropWithValueFalse, + falsePropWithValueNo, + falsePropWithValueZero, arrayProp, objProp, }: CastinProps) { @@ -203,8 +215,14 @@ describe("react-to-web-component 1", () => { stringProp, numProp, floatProp, - trueProp, - falseProp, + truePropWithValueTrue, + truePropWithValueYes, + truePropWithValueOne, + truePropWithValueFive, + truePropWithValueNine, + falsePropWithValueFalse, + falsePropWithValueNo, + falsePropWithValueZero, arrayProp, objProp, } @@ -217,8 +235,14 @@ describe("react-to-web-component 1", () => { stringProp: "string", numProp: "number", floatProp: "number", - trueProp: "boolean", - falseProp: "boolean", + truePropWithValueTrue: "boolean", + truePropWithValueYes: "boolean", + truePropWithValueOne: "boolean", + truePropWithValueFive: "boolean", + truePropWithValueNine: "boolean", + falsePropWithValueFalse: "boolean", + falsePropWithValueNo: "boolean", + falsePropWithValueZero: "boolean", arrayProp: "json", objProp: "json", }, @@ -238,8 +262,14 @@ describe("react-to-web-component 1", () => { string-prop="iloveyou" num-prop="360" float-prop="0.5" - true-prop="true" - false-prop="false" + true-prop-with-value-true="true" + true-prop-with-value-yes="yes" + true-prop-with-value-one="1" + true-prop-with-value-five="5" + true-prop-with-value-nine="9" + false-prop-with-value-false="false" + false-prop-with-value-no="no" + false-prop-with-value-zero="0" array-prop='[true, 100.25, "👽", { "aliens": "welcome" }]' obj-prop='{ "very": "object", "such": "wow!" }' > @@ -250,16 +280,28 @@ describe("react-to-web-component 1", () => { stringProp, numProp, floatProp, - trueProp, - falseProp, + truePropWithValueTrue, + truePropWithValueYes, + truePropWithValueOne, + truePropWithValueFive, + truePropWithValueNine, + falsePropWithValueFalse, + falsePropWithValueNo, + falsePropWithValueZero, arrayProp, objProp, } = global.castedValues expect(stringProp).toEqual("iloveyou") expect(numProp).toEqual(360) expect(floatProp).toEqual(0.5) - expect(trueProp).toEqual(true) - expect(falseProp).toEqual(false) + expect(truePropWithValueTrue).toEqual(true) + expect(truePropWithValueYes).toEqual(true) + expect(truePropWithValueOne).toEqual(true) + expect(truePropWithValueFive).toEqual(true) + expect(truePropWithValueNine).toEqual(true) + expect(falsePropWithValueFalse).toEqual(false) + expect(falsePropWithValueNo).toEqual(false) + expect(falsePropWithValueZero).toEqual(false) expect(arrayProp.length).toEqual(4) expect(arrayProp[0]).toEqual(true) expect(arrayProp[1]).toEqual(100.25) @@ -269,6 +311,124 @@ describe("react-to-web-component 1", () => { expect(objProp.such).toEqual("wow!") }) + it("options.props handles HTML Boolean", async () => { + expect.assertions(11) + + type CastinProps = { + truePropPresent: boolean + truePropEmptyString: boolean + truePropWithValueEqualToName: boolean + falsePropAbsent: boolean + } + + const global = window as any + + function OptionsPropsTypeCasting({ + truePropPresent, + truePropEmptyString, + truePropWithValueEqualToName, + falsePropAbsent, + }: CastinProps) { + global.castedValues = { + truePropPresent, + truePropEmptyString, + truePropWithValueEqualToName, + falsePropAbsent, + } + + return <> + } + + const WebOptionsPropsTypeCasting = r2wc(OptionsPropsTypeCasting, { + props: { + truePropPresent: "boolean", + truePropEmptyString: "boolean", + truePropWithValueEqualToName: "boolean", + falsePropAbsent: "boolean", + }, + }) + + customElements.define( + "html-boolean-attr-type-casting", + WebOptionsPropsTypeCasting, + ) + + const body = document.body + + console.error = function (...messages) { + // propTypes will throw if any of the types passed into the underlying react component are wrong or missing + expect("propTypes should not have thrown").toEqual(messages.join("")) + } + + body.innerHTML = ` + + ` + + await flushPromises() + + expect( + global.castedValues.truePropPresent, + "Prop without value is cast to true on mount", + ).toEqual(true) + expect( + global.castedValues.truePropEmptyString, + "Prop with value equal to empty string is cast to true on mount", + ).toEqual(true) + expect( + global.castedValues.truePropWithValueEqualToName, + "Prop with value equal to attribute name is considered true on mount", + ).toEqual(true) + expect( + global.castedValues.falsePropAbsent, + "Lack of prop is cast to false on mount", + ).toEqual(false) + + const element = body.querySelector("html-boolean-attr-type-casting")! + expect(element).toBeVisible() + + element.removeAttribute("true-prop-present") + element.removeAttribute("true-prop-empty-string") + element.removeAttribute("true-prop-with-value-equal-to-name") + element.setAttribute("false-prop-absent", "") + + await flushPromises() + + expect( + global.castedValues.truePropPresent, + "Prop without value is cast to false when attribute is removed", + ).toEqual(false) + expect( + global.castedValues.truePropEmptyString, + "Prop with value equal to empty string is cast to false when attribute is removed", + ).toEqual(false) + expect( + global.castedValues.truePropWithValueEqualToName, + "Prop with value equal to attribute name is cast to false when attribute is removed", + ).toEqual(false) + expect( + global.castedValues.falsePropAbsent, + "Prop which attribute was absent on mount is cast to true when it appears", + ).toEqual(true) + + // @ts-ignore + element.falsePropAbsent = false + + await flushPromises() + + expect( + element, + "Attribute of custom element is removed when property of custom element was set to false from outside", + ).not.toHaveAttribute("false-prop-absent") + expect( + global.castedValues.falsePropAbsent, + "Prop of React component is set to false when property of custom element was set to false from outside", + ).toEqual(false) + }) + it("Props typed as Function convert the string value of attribute into global fn calls bound to the webcomponent instance", async () => { expect.assertions(2)