Skip to content

Commit e87434b

Browse files
committed
HTML Boolean
Borrows some ideas from bitovi#123 Fixes bitovi#162
1 parent 002a007 commit e87434b

File tree

5 files changed

+191
-23
lines changed

5 files changed

+191
-23
lines changed

docs/api.md

+13-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
- `options.props` - Array of camelCasedProps to watch as String values or { [camelCasedProps]: "string" | "number" | "boolean" | "function" | "json" }
1111

1212
- When specifying Array or Object as the type, the string passed into the attribute must pass `JSON.parse()` requirements.
13-
- 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`.
13+
- 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`.
1414
- 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.
1515
- If PropTypes are defined on the React component, the `options.props` will be ignored and the PropTypes will be used instead.
1616
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(
127127
numProp: "number",
128128
floatProp: "number",
129129
trueProp: "boolean",
130+
htmlTruePropPresent: "boolean",
131+
htmlTruePropEmpty: "boolean",
132+
htmlTruePropSame: "boolean",
130133
falseProp: "boolean",
134+
htmlFalsePropAbsent: "boolean",
131135
arrayProp: "json",
132136
objProp: "json",
133137
},
@@ -140,10 +144,14 @@ document.body.innerHTML = `
140144
num-prop="360"
141145
float-prop="0.5"
142146
true-prop="true"
147+
html-true-prop-present
148+
html-true-prop-empty=""
149+
html-true-prop-same="html-true-prop-same"
143150
false-prop="false"
144151
array-prop='[true, 100.25, "👽", { "aliens": "welcome" }]'
145152
obj-prop='{ "very": "object", "such": "wow!" }'
146153
></attr-prop-type-casting>
154+
<!-- note the lack of html-false-prop-absent -->
147155
`
148156

149157
/*
@@ -153,7 +161,11 @@ document.body.innerHTML = `
153161
numProp: 360,
154162
floatProp: 0.5,
155163
trueProp: true,
164+
htmlTruePropPresent: true,
165+
htmlTruePropEmpty: true,
166+
htmlTruePropSame: true,
156167
falseProp: false,
168+
htmlFalsePropAbsent: false,
157169
arrayProp: [true, 100.25, "👽", { aliens: "welcome" }],
158170
objProp: { very: "object", such: "wow!" },
159171
}

packages/core/src/core.test.tsx

+8-2
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ describe("core", () => {
109109
text: string
110110
numProp: number
111111
boolProp: boolean
112+
htmlBoolProp: boolean
112113
arrProp: string[]
113114
objProp: { [key: string]: string }
114115
funcProp: () => void
@@ -118,6 +119,7 @@ describe("core", () => {
118119
text,
119120
numProp,
120121
boolProp,
122+
htmlBoolProp,
121123
arrProp,
122124
objProp,
123125
funcProp,
@@ -132,6 +134,7 @@ describe("core", () => {
132134
text: "string",
133135
numProp: "number",
134136
boolProp: "boolean",
137+
htmlBoolProp: "boolean",
135138
arrProp: "json",
136139
objProp: "json",
137140
funcProp: "function",
@@ -154,7 +157,7 @@ describe("core", () => {
154157
customElements.define("test-button-element-property", ButtonElement)
155158

156159
const body = document.body
157-
body.innerHTML = `<test-button-element-property text='hello' obj-prop='{"greeting": "hello, world"}' arr-prop='["hello", "world"]' num-prop='240' bool-prop='true' func-prop='globalFn'>
160+
body.innerHTML = `<test-button-element-property text='hello' obj-prop='{"greeting": "hello, world"}' arr-prop='["hello", "world"]' num-prop='240' bool-prop='true' html-bool-prop func-prop='globalFn'>
158161
</test-button-element-property>`
159162

160163
const element = body.querySelector(
@@ -166,6 +169,7 @@ describe("core", () => {
166169
expect(element.text).toBe("hello")
167170
expect(element.numProp).toBe(240)
168171
expect(element.boolProp).toBe(true)
172+
expect(element.htmlBoolProp).toBe(true)
169173
expect(element.arrProp).toEqual(["hello", "world"])
170174
expect(element.objProp).toEqual({ greeting: "hello, world" })
171175
expect(element.funcProp).toBeInstanceOf(Function)
@@ -174,14 +178,16 @@ describe("core", () => {
174178
element.text = "world"
175179
element.numProp = 100
176180
element.boolProp = false
181+
element.htmlBoolProp = false
177182
//@ts-ignore
178183
element.funcProp = global.newFunc
179184

180185
await wait()
181186

182187
expect(element.getAttribute("text")).toBe("world")
183188
expect(element.getAttribute("num-prop")).toBe("100")
184-
expect(element.getAttribute("bool-prop")).toBe("false")
189+
expect(element).not.toHaveAttribute("bool-prop");
190+
expect(element).not.toHaveAttribute("html-bool-prop");
185191
expect(element.getAttribute("func-prop")).toBe("newFunc")
186192
})
187193

packages/core/src/core.ts

+16-4
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,10 @@ export default function r2wc<Props extends R2WCBaseProps, Context>(
9595
const type = propTypes[prop]
9696
const transform = type ? transforms[type] : null
9797

98-
if (transform?.parse && value) {
98+
if (!value && type === "boolean") {
99+
//@ts-ignore
100+
this[propsSymbol][prop] = this.hasAttribute(attribute);
101+
} else if (transform?.parse && value) {
99102
//@ts-ignore
100103
this[propsSymbol][prop] = transform.parse(value, attribute, this)
101104
}
@@ -125,7 +128,12 @@ export default function r2wc<Props extends R2WCBaseProps, Context>(
125128
const type = propTypes[prop]
126129
const transform = type ? transforms[type] : null
127130

128-
if (prop in propTypes && transform?.parse && value) {
131+
if (!value && type === "boolean") {
132+
//@ts-ignore
133+
this[propsSymbol][prop] = this.hasAttribute(attribute)
134+
135+
this[renderSymbol]()
136+
} else if (prop in propTypes && transform?.parse && value) {
129137
//@ts-ignore
130138
this[propsSymbol][prop] = transform.parse(value, attribute, this)
131139

@@ -159,10 +167,14 @@ export default function r2wc<Props extends R2WCBaseProps, Context>(
159167
return this[propsSymbol][prop]
160168
},
161169
set(value) {
170+
const oldValue = this[propsSymbol][prop]
171+
const transform = type ? transforms[type] : null
172+
162173
this[propsSymbol][prop] = value
163174

164-
const transform = type ? transforms[type] : null
165-
if (transform?.stringify) {
175+
if (type === "boolean" && !value && oldValue) {
176+
this.removeAttribute(attribute);
177+
} else if (transform?.stringify) {
166178
//@ts-ignore
167179
const attributeValue = transform.stringify(value, attribute, this)
168180
const oldAttributeValue = this.getAttribute(attribute)

packages/core/src/transforms/boolean.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Transform } from "./index"
22

33
const boolean: Transform<boolean> = {
44
stringify: (value) => (value ? "true" : "false"),
5-
parse: (value) => /^[ty1-9]/i.test(value),
5+
parse: (value, attribute) => value === attribute || /^[ty1-9]/i.test(value),
66
}
77

88
export default boolean

packages/react-to-web-component/src/react-to-web-component.test.tsx

+153-15
Original file line numberDiff line numberDiff line change
@@ -176,14 +176,20 @@ describe("react-to-web-component 1", () => {
176176
})
177177

178178
it("options.props can specify and will convert the String attribute value into Number, Boolean, Array, and/or Object", async () => {
179-
expect.assertions(12)
179+
expect.assertions(18)
180180

181181
type CastinProps = {
182182
stringProp: string
183183
numProp: number
184184
floatProp: number
185-
trueProp: boolean
186-
falseProp: boolean
185+
truePropWithValueTrue: boolean,
186+
truePropWithValueYes: boolean,
187+
truePropWithValueOne: boolean,
188+
truePropWithValueFive: boolean,
189+
truePropWithValueNine: boolean,
190+
falsePropWithValueFalse: boolean,
191+
falsePropWithValueNo: boolean,
192+
falsePropWithValueZero: boolean,
187193
arrayProp: any[]
188194
objProp: object
189195
}
@@ -194,17 +200,29 @@ describe("react-to-web-component 1", () => {
194200
stringProp,
195201
numProp,
196202
floatProp,
197-
trueProp,
198-
falseProp,
203+
truePropWithValueTrue,
204+
truePropWithValueYes,
205+
truePropWithValueOne,
206+
truePropWithValueFive,
207+
truePropWithValueNine,
208+
falsePropWithValueFalse,
209+
falsePropWithValueNo,
210+
falsePropWithValueZero,
199211
arrayProp,
200212
objProp,
201213
}: CastinProps) {
202214
global.castedValues = {
203215
stringProp,
204216
numProp,
205217
floatProp,
206-
trueProp,
207-
falseProp,
218+
truePropWithValueTrue,
219+
truePropWithValueYes,
220+
truePropWithValueOne,
221+
truePropWithValueFive,
222+
truePropWithValueNine,
223+
falsePropWithValueFalse,
224+
falsePropWithValueNo,
225+
falsePropWithValueZero,
208226
arrayProp,
209227
objProp,
210228
}
@@ -217,8 +235,14 @@ describe("react-to-web-component 1", () => {
217235
stringProp: "string",
218236
numProp: "number",
219237
floatProp: "number",
220-
trueProp: "boolean",
221-
falseProp: "boolean",
238+
truePropWithValueTrue: "boolean",
239+
truePropWithValueYes: "boolean",
240+
truePropWithValueOne: "boolean",
241+
truePropWithValueFive: "boolean",
242+
truePropWithValueNine: "boolean",
243+
falsePropWithValueFalse: "boolean",
244+
falsePropWithValueNo: "boolean",
245+
falsePropWithValueZero: "boolean",
222246
arrayProp: "json",
223247
objProp: "json",
224248
},
@@ -238,8 +262,14 @@ describe("react-to-web-component 1", () => {
238262
string-prop="iloveyou"
239263
num-prop="360"
240264
float-prop="0.5"
241-
true-prop="true"
242-
false-prop="false"
265+
true-prop-with-value-true="true"
266+
true-prop-with-value-yes="yes"
267+
true-prop-with-value-one="1"
268+
true-prop-with-value-five="5"
269+
true-prop-with-value-nine="9"
270+
false-prop-with-value-false="false"
271+
false-prop-with-value-no="no"
272+
false-prop-with-value-zero="0"
243273
array-prop='[true, 100.25, "👽", { "aliens": "welcome" }]'
244274
obj-prop='{ "very": "object", "such": "wow!" }'
245275
></attr-type-casting>
@@ -250,16 +280,28 @@ describe("react-to-web-component 1", () => {
250280
stringProp,
251281
numProp,
252282
floatProp,
253-
trueProp,
254-
falseProp,
283+
truePropWithValueTrue,
284+
truePropWithValueYes,
285+
truePropWithValueOne,
286+
truePropWithValueFive,
287+
truePropWithValueNine,
288+
falsePropWithValueFalse,
289+
falsePropWithValueNo,
290+
falsePropWithValueZero,
255291
arrayProp,
256292
objProp,
257293
} = global.castedValues
258294
expect(stringProp).toEqual("iloveyou")
259295
expect(numProp).toEqual(360)
260296
expect(floatProp).toEqual(0.5)
261-
expect(trueProp).toEqual(true)
262-
expect(falseProp).toEqual(false)
297+
expect(truePropWithValueTrue).toEqual(true)
298+
expect(truePropWithValueYes).toEqual(true)
299+
expect(truePropWithValueOne).toEqual(true)
300+
expect(truePropWithValueFive).toEqual(true)
301+
expect(truePropWithValueNine).toEqual(true)
302+
expect(falsePropWithValueFalse).toEqual(false)
303+
expect(falsePropWithValueNo).toEqual(false)
304+
expect(falsePropWithValueZero).toEqual(false)
263305
expect(arrayProp.length).toEqual(4)
264306
expect(arrayProp[0]).toEqual(true)
265307
expect(arrayProp[1]).toEqual(100.25)
@@ -269,6 +311,102 @@ describe("react-to-web-component 1", () => {
269311
expect(objProp.such).toEqual("wow!")
270312
})
271313

314+
it("options.props handles HTML Boolean", async () => {
315+
expect.assertions(11)
316+
317+
type CastinProps = {
318+
truePropPresent: boolean,
319+
truePropEmptyString: boolean,
320+
truePropWithValueEqualToName: boolean,
321+
falsePropAbsent: boolean,
322+
}
323+
324+
const global = window as any
325+
326+
function OptionsPropsTypeCasting({
327+
truePropPresent,
328+
truePropEmptyString,
329+
truePropWithValueEqualToName,
330+
falsePropAbsent,
331+
}: CastinProps) {
332+
global.castedValues = {
333+
truePropPresent,
334+
truePropEmptyString,
335+
truePropWithValueEqualToName,
336+
falsePropAbsent,
337+
}
338+
339+
return <></>
340+
}
341+
342+
const WebOptionsPropsTypeCasting = r2wc(OptionsPropsTypeCasting, {
343+
props: {
344+
truePropPresent: "boolean",
345+
truePropEmptyString: "boolean",
346+
truePropWithValueEqualToName: "boolean",
347+
falsePropAbsent: "boolean",
348+
},
349+
})
350+
351+
customElements.define("html-boolean-attr-type-casting", WebOptionsPropsTypeCasting)
352+
353+
const body = document.body
354+
355+
console.error = function (...messages) {
356+
// propTypes will throw if any of the types passed into the underlying react component are wrong or missing
357+
expect("propTypes should not have thrown").toEqual(messages.join(""))
358+
}
359+
360+
body.innerHTML = `
361+
<html-boolean-attr-type-casting
362+
true-prop-present
363+
true-prop-empty-string=""
364+
true-prop-with-value-equal-to-name="true-html-prop-with-value-equal-to-name"
365+
></html-boolean-attr-type-casting>
366+
`
367+
368+
await flushPromises()
369+
370+
expect(global.castedValues.truePropPresent,
371+
'Prop without value is cast to true on mount').toEqual(true)
372+
expect(global.castedValues.truePropEmptyString,
373+
'Prop with value equal to empty string is cast to true on mount').toEqual(true)
374+
expect(global.castedValues.truePropWithValueEqualToName,
375+
'Prop with value equal to attribute name is considered true on mount').toEqual(true)
376+
expect(global.castedValues.falsePropAbsent,
377+
'Lack of prop is cast to false on mount').toEqual(false)
378+
379+
const element = body.querySelector('html-boolean-attr-type-casting')!
380+
expect(element).toBeVisible();
381+
382+
element.removeAttribute('true-prop-present');
383+
element.removeAttribute('true-prop-empty-string');
384+
element.removeAttribute('true-prop-with-value-equal-to-name');
385+
element.setAttribute('false-prop-absent', '');
386+
387+
await flushPromises();
388+
389+
expect(global.castedValues.truePropPresent,
390+
'Prop without value is cast to false when attribute is removed').toEqual(false)
391+
expect(global.castedValues.truePropEmptyString,
392+
'Prop with value equal to empty string is cast to false when attribute is removed').toEqual(false)
393+
expect(global.castedValues.truePropWithValueEqualToName,
394+
'Prop with value equal to attribute name is cast to false when attribute is removed').toEqual(false)
395+
expect(global.castedValues.falsePropAbsent,
396+
'Prop which attribute was absent on mount is cast to true when it appears').toEqual(true)
397+
398+
// @ts-ignore
399+
element.falsePropAbsent = false;
400+
401+
await flushPromises();
402+
403+
expect(element,
404+
'Attribute of custom element is removed when property of custom element was set to false from outside').not.toHaveAttribute('false-prop-absent')
405+
expect(global.castedValues.falsePropAbsent,
406+
'Prop of React component is set to false when property of custom element was set to false from outside').toEqual(false)
407+
})
408+
409+
272410
it("Props typed as Function convert the string value of attribute into global fn calls bound to the webcomponent instance", async () => {
273411
expect.assertions(2)
274412

0 commit comments

Comments
 (0)