diff --git a/.gitignore b/.gitignore index 7ce461c..8d8d910 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,9 @@ node_modules /es/is.d.ts /es/is.js /es/is.js.map +/es/jsx.d.ts +/es/jsx.js +/es/jsx.js.map /es/snabbdom.bundle.d.ts /es/snabbdom.bundle.js /es/snabbdom.bundle.js.map @@ -101,6 +104,10 @@ node_modules /is.d.ts /is.js /is.js.map +/jsx.d.ts +/jsx-global.d.ts +/jsx.js +/jsx.js.map /snabbdom.bundle.d.ts /snabbdom.bundle.js /snabbdom.bundle.js.map diff --git a/karma.conf.js b/karma.conf.js index 27076dd..2b2e7f2 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -30,12 +30,15 @@ module.exports = function(config) { hostname: ci ? ip : 'localhost', preprocessors: { 'src/**/*.ts': ['karma-typescript'], - 'test/**/*.js': ['karma-typescript'], + 'test/**/*.{js,ts,tsx}': ['karma-typescript'], }, browserStack: { name: 'Snabbdom', retryLimit: 3, }, + client: { + captureConsole: true, + }, browserNoActivityTimeout: 1000000, customLaunchers: browserstack, karmaTypescriptConfig: { diff --git a/package-lock.json b/package-lock.json index c333773..8fe6c8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -98,6 +98,12 @@ "through2": "^2.0.3" } }, + "@types/assert": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@types/assert/-/assert-1.4.3.tgz", + "integrity": "sha512-491hfOvNr0+BGOHT2m36xJ+LK68IuOshvxV0VIrKOnzBDL11WlDa3PwO+drTYkwCdfzJRN9REcDPZVVcrx1ucw==", + "dev": true + }, "@types/eslint-visitor-keys": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz", @@ -110,6 +116,12 @@ "integrity": "sha512-Il2DtDVRGDcqjDtE+rF8iqg1CArehSK84HZJCT7AMITlyXRBpuPhqGLDQMowraqqu1coEaimg4ZOqggt6L6L+A==", "dev": true }, + "@types/mocha": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-5.2.7.tgz", + "integrity": "sha512-NYrtPht0wGzhwe9+/idPaBB+TqkY9AhTvOLMkThm0IoEfLaiVQZwBwyJ5puCkO3AUCWrmcoePjp2mbFocKy4SQ==", + "dev": true + }, "@types/normalize-package-data": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz", @@ -3261,8 +3273,7 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "aproba": { "version": "1.2.0", @@ -3283,14 +3294,12 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -3305,20 +3314,17 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "core-util-is": { "version": "1.0.2", @@ -3435,8 +3441,7 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "ini": { "version": "1.3.5", @@ -3448,7 +3453,6 @@ "version": "1.0.0", "bundled": true, "dev": true, - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -3463,7 +3467,6 @@ "version": "3.0.4", "bundled": true, "dev": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -3471,14 +3474,12 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "minipass": { "version": "2.3.5", "bundled": true, "dev": true, - "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -3497,7 +3498,6 @@ "version": "0.5.1", "bundled": true, "dev": true, - "optional": true, "requires": { "minimist": "0.0.8" } @@ -3578,8 +3578,7 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "object-assign": { "version": "4.1.1", @@ -3591,7 +3590,6 @@ "version": "1.4.0", "bundled": true, "dev": true, - "optional": true, "requires": { "wrappy": "1" } @@ -3677,8 +3675,7 @@ "safe-buffer": { "version": "5.1.2", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "safer-buffer": { "version": "2.1.2", @@ -3714,7 +3711,6 @@ "version": "1.0.2", "bundled": true, "dev": true, - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -3734,7 +3730,6 @@ "version": "3.0.1", "bundled": true, "dev": true, - "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -3778,14 +3773,12 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "yallist": { "version": "3.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true } } }, diff --git a/package.json b/package.json index a5104f8..2c9b75d 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "test": "test" }, "devDependencies": { + "@types/assert": "^1.4.3", + "@types/mocha": "^5.2.7", "@typescript-eslint/eslint-plugin": "^2.7.0", "benchmark": "^2.1.4", "browserify": "^14.4.0", diff --git a/src/h.ts b/src/h.ts index ed4593e..da57f4e 100644 --- a/src/h.ts +++ b/src/h.ts @@ -18,17 +18,17 @@ function addNS(data: any, children: VNodes | undefined, sel: string | undefined) } export function h(sel: string): VNode; -export function h(sel: string, data: VNodeData): VNode; +export function h(sel: string, data: VNodeData | null): VNode; export function h(sel: string, children: VNodeChildren): VNode; -export function h(sel: string, data: VNodeData, children: VNodeChildren): VNode; +export function h(sel: string, data: VNodeData | null, children: VNodeChildren): VNode; export function h(sel: any, b?: any, c?: any): VNode { var data: VNodeData = {}, children: any, text: any, i: number; if (c !== undefined) { - data = b; + if (b !== null) { data = b; } if (is.array(c)) { children = c; } else if (is.primitive(c)) { text = c; } else if (c && c.sel) { children = [c]; } - } else if (b !== undefined) { + } else if (b !== undefined && b !== null) { if (is.array(b)) { children = b; } else if (is.primitive(b)) { text = b; } else if (b && b.sel) { children = [b]; } diff --git a/src/jsx-global.d.ts b/src/jsx-global.d.ts new file mode 100644 index 0000000..0ce24ef --- /dev/null +++ b/src/jsx-global.d.ts @@ -0,0 +1,14 @@ +import {VNode, VNodeData} from './vnode'; + +declare global { + /** + * opt-in jsx intrinsic global interfaces + * see: https://www.typescriptlang.org/docs/handbook/jsx.html#type-checking + */ + namespace JSX { + type Element = VNode; + interface IntrinsicElements { + [elemName: string]: VNodeData; + } + } +} diff --git a/src/jsx.ts b/src/jsx.ts new file mode 100644 index 0000000..8e35705 --- /dev/null +++ b/src/jsx.ts @@ -0,0 +1,43 @@ +import {vnode, VNode, VNodeData} from './vnode'; +import {h, ArrayOrElement} from './h'; + +// for conditional rendering we support boolean child element e.g cond && +export type JsxVNodeChild = VNode | string | number | boolean | undefined | null; +export type JsxVNodeChildren = ArrayOrElement + +export type FunctionComponent = (props: {[prop: string]: any} | null, children?: VNode[]) => VNode; + +function flattenAndFilter(children: JsxVNodeChildren[], flattened: VNode[]): VNode[] { + for (const child of children) { + // filter out falsey children, except 0 since zero can be a valid value e.g inside a chart + if (child !== undefined && child !== null && child !== false && child !== '') { + if (Array.isArray(child)) { + flattenAndFilter(child, flattened); + } else if (typeof child === 'string' || typeof child === 'number' || typeof child === 'boolean') { + flattened.push(vnode(undefined, undefined, undefined, String(child), undefined)); + } else{ + flattened.push(child); + } + } + } + return flattened; +} + +/** + * jsx/tsx compatible factory function + * see: https://www.typescriptlang.org/docs/handbook/jsx.html#factory-functions + */ +export function jsx(tag: string | FunctionComponent, data: VNodeData | null, ...children: JsxVNodeChildren[]): VNode { + const flatChildren = flattenAndFilter(children, []); + if (typeof tag === 'function') { + // tag is a function component + return tag(data, flatChildren); + } else { + if (flatChildren.length == 1 && !flatChildren[0].sel && flatChildren[0].text) { + // only child is a simple text node, pass as text for a simpler vtree + return h(tag, data, flatChildren[0].text); + } else { + return h(tag, data, flatChildren); + } + } +} diff --git a/test/core.js b/test/core.js index f4c9051..1f38cb9 100644 --- a/test/core.js +++ b/test/core.js @@ -67,6 +67,12 @@ describe('snabbdom', function() { var vnode = h('a', {}, 'I am a string'); assert.equal(vnode.text, 'I am a string'); }); + it('can create vnode with null props', function() { + var vnode = h('a', null); + assert.deepStrictEqual(vnode.data, {}); + vnode = h('a', null, ['I am a string']); + assert.equal(vnode.children[0].text, 'I am a string'); + }); it('can create vnode for comment', function() { var vnode = h('!', 'test'); assert.equal(vnode.sel, '!'); diff --git a/test/jsx.tsx b/test/jsx.tsx new file mode 100644 index 0000000..570c029 --- /dev/null +++ b/test/jsx.tsx @@ -0,0 +1,118 @@ +import * as assert from 'assert'; +import {jsx} from '../src/jsx'; + +describe('snabbdom', function() { + describe('jsx', function() { + it('can be used as a jsxFactory method', function() { + const vnode =
Hello World
; + + assert.equal(JSON.stringify(vnode), JSON.stringify({ + sel: 'div', + data: {title: 'Hello World'}, + text: 'Hello World', + })); + }); + + it('creates text property for text only child', function() { + const vnode =
foo bar
; + + assert.equal(JSON.stringify(vnode), JSON.stringify({ + sel: 'div', + data: {}, + text: 'foo bar', + })); + }); + + it('creates an array of children for multiple children', function() { + const vnode =
{'foo'}{'bar'}
; + + assert.equal(JSON.stringify(vnode), JSON.stringify({ + sel: 'div', + data: {}, + children: [ + {text: 'foo'}, + {text: 'bar'}, + ] + })); + }); + + it('flattens children', function() { + const vnode = ( +
+

A Heading

+ some description + {['part1', 'part2'].map(part => {part})} +
+ ); + + assert.equal(JSON.stringify(vnode), JSON.stringify({ + sel: 'section', + data: {}, + children: [ + {sel: 'h1', data: {}, text: 'A Heading'}, + {text: 'some description'}, + {sel: 'span', data: {}, text: 'part1'}, + {sel: 'span', data: {}, text: 'part2'}, + ], + })); + }); + + it('removes falsey children', function() { + const showLogin = false; + const showCaptcha = false; + const loginAttempts = 0; + const userName = ``; + const profilePic = undefined; + const isLoggedIn = true; + const vnode = ( +
+ Login Form + {showLogin && } + {showCaptcha ? : null} + {userName} + {profilePic} + Login Attempts: {loginAttempts} + Logged In: {isLoggedIn} +
+ ); + + assert.equal(JSON.stringify(vnode), JSON.stringify({ + sel: 'div', + data: {}, + children: [ + {text: 'Login Form'}, + {text: 'Login Attempts: '}, + {text: '0'}, + {text: 'Logged In: '}, + {text: 'true'}, + ], + })); + }); + + it('works with a function component', function() { + const Part = ({part}: {part: string}) => {part} + const vnode = ( +
+ Snabbdom + and tsx + {['work', 'like', 'a', 'charm!'].map(part => )} + {'πŸ’ƒπŸ•ΊπŸŽ‰'} +
+ ); + + assert.equal(JSON.stringify(vnode), JSON.stringify({ + sel: 'div', + data: {}, + children: [ + {sel: 'a', data: {attrs: {href: 'https://github.com/snabbdom/snabbdom'}}, text: 'Snabbdom'}, + {text: 'and tsx'}, + {sel: 'span', data: {}, text: 'work'}, + {sel: 'span', data: {}, text: 'like'}, + {sel: 'span', data: {}, text: 'a'}, + {sel: 'span', data: {}, text: 'charm!'}, + {text: 'πŸ’ƒπŸ•ΊπŸŽ‰'}, + ], + })); + }) + }); +}); diff --git a/tsconfig.json b/tsconfig.json index ed1e9b5..8c2372f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,8 @@ "declaration": true, "removeComments": false, "noUnusedLocals": true, + "jsx": "react", + "jsxFactory": "jsx", "lib": [ "dom", "es5", @@ -27,6 +29,8 @@ "src/htmldomapi.ts", "src/hooks.ts", "src/is.ts", + "src/jsx-global.d.ts", + "src/jsx.ts", "src/snabbdom.bundle.ts", "src/snabbdom.ts", "src/thunk.ts",