├── .prettierrc ├── .prettierignore ├── .gitignore ├── example ├── .gitignore ├── .babelrc ├── src │ ├── util │ │ └── range.ts │ ├── index.tsx │ ├── WcApp │ │ ├── index.tsx │ │ └── elements.tsx │ ├── StyledApp │ │ ├── index.tsx │ │ └── elements.tsx │ ├── LinariaApp │ │ ├── index.tsx │ │ └── elements.tsx │ └── App.tsx ├── package.json ├── webpack.config.js └── tsconfig.json ├── .editorconfig ├── src ├── symbol.ts ├── Slot.ts ├── util │ ├── range.ts │ └── resolveTemplateString.ts ├── HtmlComponentProps.ts ├── generateElementName.ts ├── parseTemplate.ts ├── __tests__ │ ├── __snapshots__ │ │ ├── wcIntrinsic.tsx.snap │ │ ├── declarativeShadowDOM.tsx.snap │ │ ├── wc.tsx.snap │ │ ├── classicSSR.tsx.snap │ │ └── html.tsx.snap │ ├── wcIntrinsic.tsx │ ├── declarativeShadowDOM.tsx │ ├── classicSSR.tsx │ ├── wc.tsx │ └── html.tsx ├── index.ts ├── customElementClassTemplate.ts ├── makeChildren.ts ├── html.ts ├── ssr.ts ├── applySlot.ts └── wc.ts ├── .vscode └── settings.json ├── CHANGELOG.md ├── babel.config.js ├── LICENSE ├── package.json ├── README.md ├── tsconfig.json └── jest.config.js /.prettierrc: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | README.md -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /coverage 3 | /lib -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /.linaria-cache -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_size = 2 5 | indent_style = space -------------------------------------------------------------------------------- /src/symbol.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Symbol for slot 3 | */ 4 | export const slotNameSymbol = Symbol("slot"); 5 | -------------------------------------------------------------------------------- /example/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["@babel/plugin-syntax-typescript", "@babel/plugin-syntax-jsx"], 3 | "presets": ["linaria/babel"] 4 | } 5 | -------------------------------------------------------------------------------- /src/Slot.ts: -------------------------------------------------------------------------------- 1 | import { slotNameSymbol } from "./symbol"; 2 | 3 | export type Slot = { 4 | [slotNameSymbol]: Name; 5 | }; 6 | -------------------------------------------------------------------------------- /src/util/range.ts: -------------------------------------------------------------------------------- 1 | export function* range(start: number, end: number) { 2 | for (let i = start; i < end; i++) { 3 | yield i; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.organizeImports": true 4 | }, 5 | "lit-html.tags": ["html", "wc"] 6 | } 7 | -------------------------------------------------------------------------------- /example/src/util/range.ts: -------------------------------------------------------------------------------- 1 | export function* range(start: number, end: number) { 2 | for (let i = start; i < end; i++) { 3 | yield i; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/HtmlComponentProps.ts: -------------------------------------------------------------------------------- 1 | export type HtmlComponentProps< 2 | SlotName extends string 3 | > = string extends SlotName 4 | ? {} 5 | : { [N in Exclude]?: React.ReactNode }; 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.1.1 2 | 3 | - Fixed the bug that `React.Fragment` could not be rendered in a named slot. 4 | - Supported interpolating `string` and `number`s in `html` template strings. 5 | 6 | # 0.1.0 7 | 8 | The beginning of the legend 9 | -------------------------------------------------------------------------------- /example/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { App } from "./App"; 4 | 5 | const appArea = document.createElement("div"); 6 | document.body.append(appArea); 7 | 8 | ReactDOM.render(, appArea); 9 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | targets: { 7 | node: 'current', 8 | }, 9 | }, 10 | ], 11 | '@babel/preset-react', 12 | '@babel/preset-typescript' 13 | ], 14 | }; -------------------------------------------------------------------------------- /src/generateElementName.ts: -------------------------------------------------------------------------------- 1 | let counter = 1; 2 | 3 | const prefix = "wc-"; 4 | 5 | export function generateElementName() { 6 | const randPart = Math.random().toString(36).slice(2); 7 | return `${prefix}${randPart}-${counter++}`; 8 | } 9 | 10 | export const textNodeElementName = `${prefix}text`; 11 | -------------------------------------------------------------------------------- /src/parseTemplate.ts: -------------------------------------------------------------------------------- 1 | export function parseTemplate(html: string) { 2 | const template = document.createDocumentFragment(); 3 | // parse HTML string into DOM nodes 4 | const div = document.createElement("div"); 5 | div.insertAdjacentHTML("afterbegin", html); 6 | template.append(...div.childNodes); 7 | return template; 8 | } 9 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/wcIntrinsic.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`wcIntrinsic supported section: no slot 1`] = ` 4 | " 5 | 10 |
Hello
11 | " 12 | `; 13 | 14 | exports[`wcIntrinsic supported section: no slot 2`] = `""`; 15 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { HtmlComponentProps } from "./HtmlComponentProps"; 3 | export { html, slot } from "./html"; 4 | export { ServerRenderingCollector, ServerRenderingContext } from "./ssr"; 5 | export { wc, wcIntrinsic } from "./wc"; 6 | 7 | export type WCComponent = React.FunctionComponent< 8 | HtmlComponentProps 9 | > & { 10 | readonly elementName: string; 11 | }; 12 | -------------------------------------------------------------------------------- /src/customElementClassTemplate.ts: -------------------------------------------------------------------------------- 1 | export type CustomElementClass = { 2 | template: DocumentFragment; 3 | new (): HTMLElement & { 4 | refresh(): void; 5 | }; 6 | }; 7 | 8 | export const customElementBaseClassTemplate = ` 9 | class E extends HTMLElement{ 10 | constructor(){ 11 | super(); 12 | this.attachShadow({ 13 | mode:"open" 14 | }).appendChild(E.template.cloneNode(true)) 15 | } 16 | refresh(){ 17 | let c,s=this.shadowRoot; 18 | if(s){ 19 | while(c=s.firstChild) 20 | s.removeChild(c); 21 | s.appendChild(E.template.cloneNode(true)) 22 | } 23 | } 24 | }`.replace(/\s*\n\s*/g, ""); 25 | -------------------------------------------------------------------------------- /example/src/WcApp/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { range } from "../util/range"; 3 | import { AppStyle, Counter, Counters, CounterValue } from "./elements"; 4 | 5 | export const WcApp: React.FC = () => { 6 | const [counter, setCounter] = useState(0); 7 | 8 | return ( 9 | 12 | 13 |

14 | } 15 | counter={{counter}} 16 | > 17 | 18 | {[...range(0, 256)].map((i) => ( 19 | {counter} 20 | ))} 21 | 22 |
23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /example/src/StyledApp/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { range } from "../util/range"; 3 | import { AppStyle, Counter, Counters, CounterValue } from "./elements"; 4 | 5 | export const StyledApp: React.FC = () => { 6 | const [counter, setCounter] = useState(0); 7 | 8 | return ( 9 | 12 | 13 |

14 | } 15 | counter={{counter}} 16 | > 17 | 18 | {[...range(0, 256)].map((i) => ( 19 | {counter} 20 | ))} 21 | 22 |
23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /example/src/LinariaApp/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { range } from "../util/range"; 3 | import { AppStyle, Counter, Counters, CounterValue } from "./elements"; 4 | 5 | export const LinariaApp: React.FC = () => { 6 | const [counter, setCounter] = useState(0); 7 | 8 | return ( 9 | 12 | 13 |

14 | } 15 | counter={{counter}} 16 | > 17 | 18 | {[...range(0, 256)].map((i) => ( 19 | {counter} 20 | ))} 21 | 22 |
23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /src/makeChildren.ts: -------------------------------------------------------------------------------- 1 | import { applySlot } from "./applySlot"; 2 | import { HtmlComponentProps } from "./HtmlComponentProps"; 3 | 4 | export function makeChildren( 5 | props: React.PropsWithChildren>, 6 | slotNames?: readonly SlotName[] 7 | ): React.ReactNode { 8 | if (!slotNames || slotNames.length === 0) { 9 | return props.children; 10 | } 11 | const p: HtmlComponentProps = props; 12 | const result: React.ReactNode[] = []; 13 | for (const slot of slotNames) { 14 | const prop = p[ 15 | (slot as unknown) as keyof HtmlComponentProps 16 | ] as React.ReactNode; 17 | result.push(applySlot(prop, slot, `slot-${slot}`)); 18 | } 19 | result.push(props.children); 20 | return result; 21 | } 22 | -------------------------------------------------------------------------------- /example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { LinariaApp } from "./LinariaApp"; 3 | import { StyledApp } from "./StyledApp"; 4 | import { WcApp } from "./WcApp"; 5 | 6 | const modes = ["wc", "linaria", "styled"] as const; 7 | 8 | export const App: React.FC = () => { 9 | const [mode, setMode] = useState("wc"); 10 | 11 | return ( 12 |
13 |

14 | Mode{" "} 15 | 23 |

24 | {mode === "wc" ? ( 25 | 26 | ) : mode === "linaria" ? ( 27 | 28 | ) : ( 29 | 30 | )} 31 |
32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 uhyo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-wc-example", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "webpack-dev-server", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "keywords": [], 11 | "author": "uhyo ", 12 | "license": "MIT", 13 | "devDependencies": { 14 | "@babel/core": "^7.11.6", 15 | "@babel/plugin-syntax-jsx": "^7.10.4", 16 | "@babel/plugin-syntax-typescript": "^7.10.4", 17 | "@babel/preset-react": "^7.10.4", 18 | "@types/react": "^16.9.50", 19 | "@types/react-dom": "^16.9.8", 20 | "@types/styled-components": "^5.1.3", 21 | "babel-loader": "^8.1.0", 22 | "css-loader": "^4.3.0", 23 | "html-webpack-plugin": "^4.5.0", 24 | "mini-css-extract-plugin": "^0.11.3", 25 | "react": "^16.13.1", 26 | "react-dom": "^16.13.1", 27 | "styled-components": "^5.2.0", 28 | "ts-loader": "^8.0.4", 29 | "typescript": "^4.0.3", 30 | "webpack": "^4.44.2", 31 | "webpack-cli": "^3.3.12", 32 | "webpack-dev-server": "^3.11.0" 33 | }, 34 | "dependencies": { 35 | "linaria": "^1.3.3" 36 | } 37 | } -------------------------------------------------------------------------------- /example/src/WcApp/elements.tsx: -------------------------------------------------------------------------------- 1 | import { html, slot } from "react-wc"; 2 | 3 | export const AppStyle = html` 4 | 13 |
${slot("header")}
14 |

Counter value is ${slot("counter")}

15 |
${slot()}
16 | `; 17 | 18 | export const CounterValue = html` 19 | 24 | ${slot()} 25 | `; 26 | 27 | export const Counters = html` 28 | 35 |
${slot()}
36 | `; 37 | 38 | export const Counter = html` 39 | 53 |
${slot()}
54 | `; 55 | -------------------------------------------------------------------------------- /src/__tests__/wcIntrinsic.tsx: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | import { render } from "@testing-library/react"; 3 | import React from "react"; 4 | import { wcIntrinsic } from ".."; 5 | 6 | describe("wcIntrinsic", () => { 7 | describe("supported", () => { 8 | it("section: no slot", () => { 9 | const Hello = wcIntrinsic({ 10 | element: "section", 11 | shadowHtml: ` 12 | 17 |
Hello
18 | `, 19 | }); 20 | 21 | render(); 22 | 23 | const el = document.getElementsByTagName("section")[0]; 24 | 25 | expect(el.shadowRoot?.innerHTML).toMatchSnapshot(); 26 | expect(el.innerHTML).toMatchSnapshot(); 27 | }); 28 | }); 29 | describe("not supported", () => { 30 | it("input", () => { 31 | const Input = wcIntrinsic({ 32 | element: "input", 33 | shadowHtml: ` 34 | 39 | `, 40 | }); 41 | 42 | expect(() => render()).toThrow(); 43 | }); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-wc", 3 | "version": "0.2.4", 4 | "description": "React wrapper for Web Components", 5 | "main": "lib/index.js", 6 | "types": "lib/index.d.ts", 7 | "scripts": { 8 | "build": "tsc", 9 | "watch": "tsc --watch", 10 | "prepublishOnly": "npm run build", 11 | "test": "jest", 12 | "test:update": "jest -u", 13 | "test:watch": "jest --watch" 14 | }, 15 | "keywords": [], 16 | "author": "uhyo ", 17 | "license": "MIT", 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/uhyo/react-wc.git" 21 | }, 22 | "devDependencies": { 23 | "@babel/core": "^7.11.6", 24 | "@babel/preset-env": "^7.11.5", 25 | "@babel/preset-react": "^7.10.4", 26 | "@babel/preset-typescript": "^7.10.4", 27 | "@testing-library/jest-dom": "^5.11.4", 28 | "@testing-library/react": "^11.0.4", 29 | "@types/jest": "^26.0.14", 30 | "@types/react": "^16.9.50", 31 | "@types/react-dom": "^16.9.9", 32 | "babel-jest": "^26.3.0", 33 | "jest": "^26.4.2", 34 | "react": "^16.13.1", 35 | "react-dom": "^16.13.1", 36 | "typescript": "^4.0.3" 37 | }, 38 | "peerDependencies": { 39 | "react": "^16.13.1 || ^17.0.0" 40 | }, 41 | "files": [ 42 | "src/**/*", 43 | "lib/**/*" 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /example/src/StyledApp/elements.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | 4 | const AppStyled = styled.div` 5 | & > header { 6 | border: 1px solid #cccccc; 7 | padding: 4px; 8 | } 9 | & > p { 10 | border-bottom: 1px dashed #999999; 11 | } 12 | `; 13 | 14 | export const AppStyle: React.FunctionComponent<{ 15 | header: React.ReactElement | null; 16 | counter: React.ReactElement | null; 17 | }> = ({ header, counter, children }) => ( 18 | 19 |
{header}
20 |

Counter value is {counter}

21 |
{children}
22 |
23 | ); 24 | 25 | export const CounterValue = styled.span` 26 | font-weight: bold; 27 | `; 28 | 29 | export const Counters = styled.div` 30 | display: grid; 31 | grid: auto-flow / repeat(16, 80px); 32 | gap: 10px; 33 | `; 34 | 35 | const CounterStyle = styled.div` 36 | display: flex; 37 | flex-flow: nowrap row; 38 | justify-content: center; 39 | align-items: center; 40 | box-sizing: border-box; 41 | width: 80px; 42 | height: 80px; 43 | border: 1px solid #cccccc; 44 | padding: 2px; 45 | font-size: 1.5em; 46 | `; 47 | 48 | export const Counter: React.FC = ({ children }) => ( 49 | 50 |
{children}
51 |
52 | ); 53 | -------------------------------------------------------------------------------- /example/src/LinariaApp/elements.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "linaria"; 2 | import { styled } from "linaria/react"; 3 | import React from "react"; 4 | 5 | const app = css` 6 | & > header { 7 | border: 1px solid #cccccc; 8 | padding: 4px; 9 | } 10 | & > p { 11 | border-bottom: 1px dashed #999999; 12 | } 13 | `; 14 | export const AppStyle: React.FunctionComponent<{ 15 | header: React.ReactElement | null; 16 | counter: React.ReactElement | null; 17 | }> = ({ header, counter, children }) => ( 18 |
19 |
{header}
20 |

Counter value is {counter}

21 |
{children}
22 |
23 | ); 24 | 25 | export const CounterValue = styled.span` 26 | font-weight: bold; 27 | `; 28 | 29 | const counters = css` 30 | display: grid; 31 | grid: auto-flow / repeat(16, 80px); 32 | gap: 10px; 33 | `; 34 | 35 | export const Counters: React.FC = ({ children }) => ( 36 |
{children}
37 | ); 38 | 39 | const counter = css` 40 | display: flex; 41 | flex-flow: nowrap row; 42 | justify-content: center; 43 | align-items: center; 44 | box-sizing: border-box; 45 | width: 80px; 46 | height: 80px; 47 | border: 1px solid #cccccc; 48 | padding: 2px; 49 | font-size: 1.5em; 50 | `; 51 | 52 | export const Counter: React.FC = ({ children }) => ( 53 |
54 |
{children}
55 |
56 | ); 57 | -------------------------------------------------------------------------------- /example/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const HtmlWebpackPlugin = require('html-webpack-plugin') 3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 4 | 5 | module.exports = { 6 | entry: './src/index.tsx', 7 | devtool: "inline-source-map", 8 | output: { 9 | path: __dirname + '/dist', 10 | filename: 'bundle.js' 11 | }, 12 | resolve: { 13 | extensions: [".ts", ".tsx", ".js"], 14 | alias: { 15 | "react-wc": path.join(__dirname, "../") 16 | } 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.tsx?$/, use: [ 22 | // "babel-loader", 23 | { 24 | loader: 'linaria/loader', 25 | options: { 26 | sourceMap: process.env.NODE_ENV !== 'production', 27 | }, 28 | }, 29 | "ts-loader", 30 | // "babel-loader", 31 | ] 32 | }, { 33 | test: /\.css$/, 34 | use: [ 35 | { 36 | loader: MiniCssExtractPlugin.loader, 37 | options: { 38 | hmr: process.env.NODE_ENV !== 'production', 39 | }, 40 | }, 41 | { 42 | loader: 'css-loader', 43 | options: { 44 | sourceMap: process.env.NODE_ENV !== 'production', 45 | }, 46 | }, 47 | ], 48 | }, 49 | ] 50 | }, 51 | plugins: [ 52 | new HtmlWebpackPlugin(), 53 | new MiniCssExtractPlugin({ 54 | filename: 'styles.css', 55 | }) 56 | ] 57 | } -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/declarativeShadowDOM.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`declarativeShadowDOM wc named slot 1`] = ` 4 | "Hi" 12 | `; 13 | 14 | exports[`declarativeShadowDOM wc one slot 1`] = ` 15 | "Foobar" 23 | `; 24 | 25 | exports[`declarativeShadowDOM wcIntrinsic one slot 1`] = ` 26 | "Foobar" 34 | `; 35 | 36 | exports[`declarativeShadowDOM wcIntrinsic two slots 1`] = ` 37 | "
Hiwow
" 45 | `; 46 | -------------------------------------------------------------------------------- /src/util/resolveTemplateString.ts: -------------------------------------------------------------------------------- 1 | import { Slot } from "../Slot"; 2 | import { slotNameSymbol } from "../symbol"; 3 | import { range } from "./range"; 4 | 5 | export type HtmlInterpolationValue = 6 | | Slot 7 | | string 8 | | number; 9 | 10 | export function resolveTemplateString( 11 | arr: TemplateStringsArray, 12 | values: readonly HtmlInterpolationValue[] 13 | ): [string, Exclude[]] { 14 | let result = arr[0]; 15 | const slotNames = []; 16 | for (const i of range(0, values.length)) { 17 | const val = values[i]; 18 | if (typeof val !== "object") { 19 | result += escapeName(String(val)); 20 | } else { 21 | const slotName = val[slotNameSymbol]; 22 | if (slotName && slotName !== "children") { 23 | result += ``; 24 | slotNames.push(slotName as Exclude); 25 | } else { 26 | result += ""; 27 | } 28 | } 29 | result += arr[i + 1]; 30 | } 31 | return [result, slotNames]; 32 | } 33 | 34 | function replacer(char: string) { 35 | switch (char) { 36 | case "&": 37 | return "&"; 38 | case "<": 39 | return "<"; 40 | case ">": 41 | return ">"; 42 | case '"': 43 | return """; 44 | case "'": 45 | return "'"; 46 | /* istanbul ignore next */ 47 | default: 48 | return char; 49 | } 50 | } 51 | 52 | function escapeName(name: string) { 53 | return name.replace(/[&<>"']/g, replacer); 54 | } 55 | -------------------------------------------------------------------------------- /src/html.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { WCComponent } from "."; 3 | import { generateElementName } from "./generateElementName"; 4 | import { HtmlComponentProps } from "./HtmlComponentProps"; 5 | import { makeChildren } from "./makeChildren"; 6 | import { Slot } from "./Slot"; 7 | import { slotNameSymbol } from "./symbol"; 8 | import { 9 | HtmlInterpolationValue, 10 | resolveTemplateString, 11 | } from "./util/resolveTemplateString"; 12 | 13 | /** 14 | * Creates a component from given HTML string. 15 | * @deprecated Use `@castella/macro` package instead. 16 | */ 17 | export function html( 18 | html: TemplateStringsArray, 19 | ...values: readonly HtmlInterpolationValue[] 20 | ): WCComponent { 21 | const elementName = generateElementName(); 22 | let initFlag = false; 23 | const template = document.createDocumentFragment(); 24 | const [templateString, slotNames] = resolveTemplateString(html, values); 25 | return Object.assign( 26 | (props: React.PropsWithChildren>) => { 27 | if (!initFlag) { 28 | initFlag = true; 29 | // parse HTML string into DOM nodes 30 | const div = document.createElement("div"); 31 | div.insertAdjacentHTML("afterbegin", templateString); 32 | template.append(...div.childNodes); 33 | class Elm extends HTMLElement { 34 | constructor() { 35 | super(); 36 | this.attachShadow({ 37 | mode: "open", 38 | }).appendChild(template.cloneNode(true)); 39 | } 40 | } 41 | window.customElements.define(elementName, Elm); 42 | } 43 | const children = makeChildren(props, slotNames); 44 | return React.createElement(elementName, {}, children); 45 | }, 46 | { 47 | elementName, 48 | } 49 | ); 50 | } 51 | 52 | /** 53 | * @deprecated Use `@castella/macro` instead. 54 | */ 55 | export function slot(): Slot<"">; 56 | export function slot(name: Name): Slot; 57 | export function slot(name?: string): Slot { 58 | return { 59 | [slotNameSymbol]: name || "", 60 | }; 61 | } 62 | -------------------------------------------------------------------------------- /src/__tests__/declarativeShadowDOM.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | import React from "react"; 5 | import { renderToString } from "react-dom/server"; 6 | import { wc, wcIntrinsic } from ".."; 7 | 8 | describe("declarativeShadowDOM", () => { 9 | describe("wc", () => { 10 | it("one slot", () => { 11 | const Hello = wc({ 12 | shadowHtml: ` 13 | 18 |
19 | `, 20 | slots: [], 21 | name: "wc-test-foo", 22 | declarativeShadowDOM: true, 23 | }); 24 | 25 | const str = renderToString( 26 | 27 | Foobar 28 | 29 | ); 30 | 31 | expect(str).toMatchSnapshot(); 32 | }); 33 | it("named slot", () => { 34 | const Hello = wc({ 35 | shadowHtml: ` 36 | 41 | 42 | `, 43 | slots: ["foo"], 44 | name: "wc-test-2", 45 | declarativeShadowDOM: true, 46 | }); 47 | 48 | const str = renderToString(Hi} />); 49 | 50 | expect(str).toMatchSnapshot(); 51 | }); 52 | }); 53 | describe("wcIntrinsic", () => { 54 | it("one slot", () => { 55 | const Hello = wcIntrinsic({ 56 | shadowHtml: ` 57 | 62 | 63 | `, 64 | slots: [], 65 | element: "span", 66 | declarativeShadowDOM: true, 67 | }); 68 | 69 | const str = renderToString( 70 | 71 | Foobar 72 | 73 | ); 74 | 75 | expect(str).toMatchSnapshot(); 76 | }); 77 | it("two slots", () => { 78 | const Hello = wcIntrinsic({ 79 | shadowHtml: ` 80 | 85 |
86 | `, 87 | slots: ["foo", "bar"], 88 | element: "div", 89 | declarativeShadowDOM: true, 90 | }); 91 | 92 | const str = renderToString(Hi} bar="wow" />); 93 | 94 | expect(str).toMatchSnapshot(); 95 | }); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/wc.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`wc Named slot values array 1`] = `"Helloval is foobarval is wow"`; 4 | 5 | exports[`wc Named slot values boolean 1`] = `""`; 6 | 7 | exports[`wc Named slot values class component 1`] = `"val is aaaaa"`; 8 | 9 | exports[`wc Named slot values fragment 1`] = `"HiHelloval is foobarval is wow"`; 10 | 11 | exports[`wc Named slot values function component 1`] = `"val is abcde"`; 12 | 13 | exports[`wc Named slot values intrinsic element 1`] = `"Hello"`; 14 | 15 | exports[`wc Named slot values nested class component 1`] = `"val is aaaaaaaaaaaaaaa"`; 16 | 17 | exports[`wc Named slot values nested function component 1`] = `"val is abcdeabcdeabcde"`; 18 | 19 | exports[`wc Named slot values null 1`] = `""`; 20 | 21 | exports[`wc Named slot values number 1`] = `"123"`; 22 | 23 | exports[`wc Named slot values reused function component 1`] = `"val is def"`; 24 | 25 | exports[`wc Named slot values text 1`] = `"foobar"`; 26 | 27 | exports[`wc Named slot values undefined 1`] = `""`; 28 | 29 | exports[`wc basic multiple slots 1`] = ` 30 | " 31 | 36 |
37 |
38 |
39 | " 40 | `; 41 | 42 | exports[`wc basic multiple slots 2`] = `"head!foo!Foobar"`; 43 | 44 | exports[`wc basic no slot 1`] = ` 45 | " 46 | 51 |
Hello
52 | " 53 | `; 54 | 55 | exports[`wc basic no slot 2`] = `""`; 56 | 57 | exports[`wc basic one slot 1`] = ` 58 | " 59 | 64 |
65 | " 66 | `; 67 | 68 | exports[`wc basic one slot 2`] = `"Foobar"`; 69 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/classicSSR.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Classic SSR wc many components 1`] = `"Hi
foo
Hey
"`; 4 | 5 | exports[`Classic SSR wc many components 2`] = `""`; 6 | 7 | exports[`Classic SSR wc named slot 1`] = `"Hi"`; 8 | 9 | exports[`Classic SSR wc named slot 2`] = `""`; 10 | 11 | exports[`Classic SSR wc one slot 1`] = `"Foobar"`; 12 | 13 | exports[`Classic SSR wc one slot 2`] = `""`; 14 | -------------------------------------------------------------------------------- /src/__tests__/classicSSR.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | import React, { Fragment } from "react"; 5 | import { renderToStaticMarkup, renderToString } from "react-dom/server"; 6 | import { ServerRenderingCollector, wc } from ".."; 7 | 8 | describe("Classic SSR", () => { 9 | describe("wc", () => { 10 | it("one slot", () => { 11 | const Hello = wc({ 12 | shadowHtml: ` 13 | 18 |
19 | `, 20 | slots: [], 21 | name: "wc-test-foo", 22 | classicSSR: true, 23 | }); 24 | 25 | const collector = new ServerRenderingCollector(); 26 | const str = renderToString( 27 | collector.wrap( 28 | 29 | Foobar 30 | 31 | ) 32 | ); 33 | 34 | expect(str).toMatchSnapshot(); 35 | expect( 36 | renderToStaticMarkup({collector.getHeadElements()}) 37 | ).toMatchSnapshot(); 38 | }); 39 | it("named slot", () => { 40 | const Hello = wc({ 41 | shadowHtml: ` 42 | 47 | 48 | `, 49 | slots: ["foo"], 50 | name: "wc-test-2", 51 | classicSSR: true, 52 | }); 53 | 54 | const collector = new ServerRenderingCollector(); 55 | const str = renderToString( 56 | collector.wrap(Hi} />) 57 | ); 58 | 59 | expect(str).toMatchSnapshot(); 60 | expect( 61 | renderToStaticMarkup({collector.getHeadElements()}) 62 | ).toMatchSnapshot(); 63 | }); 64 | it("many components", () => { 65 | const Hello = wc({ 66 | shadowHtml: ` 67 | 72 | 73 | 74 | `, 75 | slots: ["foo"], 76 | name: "wc-test-many-components-1", 77 | classicSSR: true, 78 | }); 79 | const Hello2 = wc({ 80 | shadowHtml: ` 81 | 84 | 85 | `, 86 | slots: ["foo"], 87 | name: "wc-test-many-components-2", 88 | classicSSR: true, 89 | }); 90 | 91 | const collector = new ServerRenderingCollector(); 92 | const str = renderToString( 93 | collector.wrap( 94 | Hi}> 95 |
96 | 97 |
98 | Hey} /> 99 |
100 | ) 101 | ); 102 | 103 | expect(str).toMatchSnapshot(); 104 | expect( 105 | renderToStaticMarkup({collector.getHeadElements()}) 106 | ).toMatchSnapshot(); 107 | }); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-wc 2 | 3 | > Tiny wrapper of Web Components for react. 4 | 5 | ## What `react-wc` does? 6 | 7 | It creates a React component which renders a custom element with given HTML fragment in its Shadow DOM. 8 | 9 | ## Looking for a WebComponents-based CSS-in-JS solution for React? 10 | 11 | This package provides some low-level functionality that helps other Web Components-based libraries. 12 | 13 | If you are not a package author, you may be interested in [Castella](https://github.com/uhyo/castella), a CSS-in-JS library for React backed by this package. 14 | 15 | ## Usage 16 | 17 | ### Defining a Component 18 | 19 | ```ts 20 | import { wc } from "react-wc"; 21 | 22 | export const Counters = wc({ 23 | shadowHtml: ` 24 | 31 |
32 | `, 33 | name: "my-counter-element" 34 | }); 35 | ``` 36 | 37 | ### Using Defined Component 38 | 39 | ```tsx 40 | child 41 | ``` 42 | 43 | ### Rendered DOM 44 | 45 | ```html 46 | 47 | #shadow-root 48 | 55 |
56 | child 57 |
58 | ``` 59 | 60 | ### Named Slots 61 | 62 | ```ts 63 | const AppStyle = wc({ 64 | shadowHtml: ` 65 | 74 |
75 |

Counter value is

76 |
77 | `, 78 | slots: ["header", "counter"], 79 | element: "my-app-style" 80 | }); 81 | 82 | const CounterValue = ({ children }) => {children}; 83 | ``` 84 | 85 | Named slots can be filled by a prop with the same name. Any React nodes can be passed to those props. 86 | 87 | ```tsx 88 | 91 | 92 |

93 | } 94 | counter={{counter}} 95 | > 96 | 97 | {[...range(0, 256)].map((i) => ( 98 | {counter} 99 | ))} 100 | 101 |
102 | ``` 103 | 104 | #### Rendered DOM 105 | 106 | Rendered DOM elements are automatically given the `slot` attribute so they fill corresponding ``s. `react-wc` does all the work needed to achieve this. 107 | 108 | ```html 109 | 110 | #shadow-dom ... 111 |

112 | 113 |

114 | 1 115 | ... 116 |
117 | ``` 118 | 119 | ## Further Reading 120 | 121 | - [Blog article (日本語)](https://blog.uhy.ooo/entry/2020-10-03/react-wc/) 122 | 123 | ## Future Roadmap 124 | 125 | - [ ] SSR Support? 126 | 127 | ## Contributing 128 | 129 | Welcome 130 | 131 | ## License 132 | 133 | MIT -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/html.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`html Named slot values array 1`] = `"Helloval is foobarval is wow"`; 4 | 5 | exports[`html Named slot values boolean 1`] = `""`; 6 | 7 | exports[`html Named slot values class component 1`] = `"val is aaaaa"`; 8 | 9 | exports[`html Named slot values fragment 1`] = `"HiHelloval is foobarval is wow"`; 10 | 11 | exports[`html Named slot values function component 1`] = `"val is abcde"`; 12 | 13 | exports[`html Named slot values intrinsic element 1`] = `"Hello"`; 14 | 15 | exports[`html Named slot values nested class component 1`] = `"val is aaaaaaaaaaaaaaa"`; 16 | 17 | exports[`html Named slot values nested function component 1`] = `"val is abcdeabcdeabcde"`; 18 | 19 | exports[`html Named slot values null 1`] = `""`; 20 | 21 | exports[`html Named slot values number 1`] = `"123"`; 22 | 23 | exports[`html Named slot values reused function component 1`] = `"val is def"`; 24 | 25 | exports[`html Named slot values text 1`] = `"foobar"`; 26 | 27 | exports[`html Named slot values undefined 1`] = `""`; 28 | 29 | exports[`html basic multiple slots 1`] = ` 30 | " 31 | 36 |
37 |
38 |
39 | " 40 | `; 41 | 42 | exports[`html basic multiple slots 2`] = `"head!foo!Foobar"`; 43 | 44 | exports[`html basic no slot 1`] = ` 45 | " 46 | 51 |
Hello
52 | " 53 | `; 54 | 55 | exports[`html basic no slot 2`] = `""`; 56 | 57 | exports[`html basic one slot 1`] = ` 58 | " 59 | 64 |
65 | " 66 | `; 67 | 68 | exports[`html basic one slot 2`] = `"Foobar"`; 69 | 70 | exports[`html escape escaping interpolated string 1`] = ` 71 | " 72 | 77 |
" '&!\\">
78 | <b>Hello, world!</b> 79 | " 80 | `; 81 | 82 | exports[`html escape escaping slot name 1`] = ` 83 | " 84 | 89 |
"\\">
90 | " 91 | `; 92 | 93 | exports[`html escape escaping slot name 2`] = `""\\">Foobar"`; 94 | 95 | exports[`html interpolation String interpolation 1`] = ` 96 | " 97 | 102 |
103 | " 104 | `; 105 | 106 | exports[`html interpolation String interpolation 2`] = `"Foobar"`; 107 | -------------------------------------------------------------------------------- /src/ssr.ts: -------------------------------------------------------------------------------- 1 | import React, { createContext } from "react"; 2 | import { customElementBaseClassTemplate } from "./customElementClassTemplate"; 3 | 4 | declare global { 5 | namespace ReactWC { 6 | interface ServerRenderingGetHeadElementsOptions { 7 | scriptProps?: JSX.IntrinsicElements["script"]; 8 | } 9 | } 10 | } 11 | 12 | export const reactWcGlobal = { 13 | /** 14 | * Name of global variable. 15 | */ 16 | varName: "__REACT_WC__", 17 | /** 18 | * Name of getCustomElementClass function 19 | */ 20 | getCustomElementClass: "c", 21 | /** 22 | * Named of parseTemplate function 23 | */ 24 | parseTemplate: "p", 25 | }; 26 | 27 | /** 28 | * Class for collecting data during SSR. 29 | */ 30 | export class ServerRenderingCollector { 31 | readonly scripts = new Map(); 32 | 33 | /** 34 | * @internal 35 | */ 36 | addScriptForCustomElement(name: string, shadowHtml: string) { 37 | const script = 38 | `customElements.define("${escapeForDoubleQuotedString(name)}",` + 39 | `(E=>(E.template=${reactWcGlobal.varName}.${ 40 | reactWcGlobal.parseTemplate 41 | }("${escapeForDoubleQuotedString(shadowHtml)}"),E))(${ 42 | reactWcGlobal.varName 43 | }.${reactWcGlobal.getCustomElementClass}()))`; 44 | this.scripts.set(name, script); 45 | } 46 | 47 | /** 48 | * Returns a list of elements that are to be rendered in . 49 | */ 50 | getHeadElements( 51 | options?: ReactWC.ServerRenderingGetHeadElementsOptions 52 | ): JSX.Element[] { 53 | const scriptProps = options?.scriptProps; 54 | let scripts = ""; 55 | for (const script of this.scripts.values()) { 56 | scripts += script + ";"; 57 | } 58 | if (scripts) { 59 | scripts = 60 | `var ${reactWcGlobal.varName}={` + 61 | `${reactWcGlobal.getCustomElementClass}:()=>${customElementBaseClassTemplate},` + 62 | `${reactWcGlobal.parseTemplate}:(t,d)=>{` + 63 | `return d=document.createElement("div"),d.insertAdjacentHTML("afterbegin",t),` + 64 | `t=document.createDocumentFragment(),t.appendChild(...d.childNodes),t` + 65 | `}};${scripts}`; 66 | return [ 67 | React.createElement("script", { 68 | key: "react-wc-script", 69 | ...scriptProps, 70 | dangerouslySetInnerHTML: { 71 | __html: scripts, 72 | }, 73 | }), 74 | ]; 75 | } 76 | return []; 77 | } 78 | 79 | /** 80 | * Wrap a React element to collect SSR information. 81 | */ 82 | wrap(component: JSX.Element): JSX.Element { 83 | return React.createElement( 84 | ServerRenderingContext.Provider, 85 | { 86 | value: this, 87 | }, 88 | component 89 | ); 90 | } 91 | } 92 | 93 | /** 94 | * Context for collectiong styles needed for SSR. 95 | */ 96 | export const ServerRenderingContext = createContext< 97 | ServerRenderingCollector | undefined 98 | >(undefined); 99 | 100 | const doubleQuotedEscapeRegExp = /"|\\|\r|\n|\u2028|\u2029|<\/script>/g; 101 | const doubleQuoteEscapeFunc = (str: string) => { 102 | if (str === '"') { 103 | return '\\"'; 104 | } 105 | if (str === "\\") { 106 | return "\\\\"; 107 | } 108 | if (str === "\r") { 109 | return "\\r"; 110 | } 111 | if (str === "\n") { 112 | return "\\n"; 113 | } 114 | if (str === "") { 115 | return "<\\/script>"; 116 | } 117 | if (str === "\u2028") { 118 | return "\\u2028"; 119 | } 120 | if (str === "\u2029") { 121 | return "\\u2029"; 122 | } 123 | return str; 124 | }; 125 | function escapeForDoubleQuotedString(str: string) { 126 | return str.replace(doubleQuotedEscapeRegExp, doubleQuoteEscapeFunc); 127 | } 128 | -------------------------------------------------------------------------------- /src/applySlot.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { textNodeElementName } from "./generateElementName"; 3 | 4 | let textElementRegistered = false; 5 | const hasCustomElements = 6 | typeof window !== "undefined" && !!window.customElements; 7 | 8 | export function applySlot( 9 | node: React.ReactNode, 10 | slotName: string, 11 | addedKey?: string | number 12 | ): React.ReactNode { 13 | if (Array.isArray(node)) { 14 | const children = node.map((n, i) => applySlot(n, slotName, `wc-slot-${i}`)); 15 | if (addedKey) { 16 | return React.createElement( 17 | React.Fragment, 18 | { 19 | key: addedKey, 20 | }, 21 | children 22 | ); 23 | } 24 | return children; 25 | } 26 | if (typeof node !== "object") { 27 | if (!textElementRegistered) { 28 | textElementRegistered = true; 29 | if (hasCustomElements) { 30 | window.customElements.define( 31 | textNodeElementName, 32 | class extends HTMLElement {} 33 | ); 34 | } 35 | } 36 | return React.createElement( 37 | textNodeElementName, 38 | { 39 | key: addedKey, 40 | slot: slotName, 41 | }, 42 | node 43 | ); 44 | } 45 | if (isReactElement(node)) { 46 | const { type } = node; 47 | if (typeof type === "string") { 48 | return { 49 | ...node, 50 | props: { 51 | ...node.props, 52 | slot: slotName, 53 | }, 54 | key: node.key ?? addedKey, 55 | }; 56 | } else if (typeof type === "symbol") { 57 | const t: symbol = type; 58 | /* istanbul ignore else */ 59 | if (t.description === "react.fragment") { 60 | // react.fragment 61 | return { 62 | ...node, 63 | props: { 64 | ...node.props, 65 | children: applySlot(node.props.children, slotName), 66 | }, 67 | key: node.key ?? addedKey, 68 | }; 69 | } else { 70 | throw new Error("Could not handle node of type " + String(t)); 71 | } 72 | } 73 | const { 74 | props: { children, ...props }, 75 | key, 76 | } = node; 77 | const w = wrappers.get(type); 78 | if (w) { 79 | return React.createElement( 80 | w, 81 | { 82 | ...props, 83 | key: key ?? addedKey, 84 | slot: slotName, 85 | }, 86 | children 87 | ); 88 | } 89 | const newWrapper = makeWrapper(type); 90 | wrappers.set(type, newWrapper); 91 | return React.createElement( 92 | newWrapper, 93 | { 94 | ...props, 95 | key: key ?? addedKey, 96 | slot: slotName, 97 | }, 98 | children 99 | ); 100 | } 101 | return null; 102 | } 103 | 104 | const wrappers = new WeakMap< 105 | React.JSXElementConstructor, 106 | React.ComponentType 107 | >(); 108 | 109 | function makeWrapper( 110 | element: React.JSXElementConstructor 111 | ): React.ComponentType { 112 | if (isClassComponent(element)) { 113 | return class extends element { 114 | render() { 115 | const children = super.render(); 116 | return React.createElement(React.Fragment, { 117 | children: applySlot(children, this.props.slot), 118 | }); 119 | } 120 | }; 121 | } 122 | return ({ slot, ...props }) => { 123 | const children = element(props); 124 | return React.createElement(React.Fragment, { 125 | children: applySlot(children, slot), 126 | }); 127 | }; 128 | } 129 | 130 | function isReactElement(node: any): node is React.ReactElement { 131 | return node != null && node.type !== undefined; 132 | } 133 | 134 | function isClassComponent( 135 | element: React.JSXElementConstructor 136 | ): element is new (props: any) => React.Component { 137 | return !!element.prototype && !!element.prototype.isReactComponent; 138 | } 139 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es2019" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 8 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 9 | // "lib": [], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */, 13 | "declaration": true /* Generates corresponding '.d.ts' file. */, 14 | "declarationMap": true /* Generates a sourcemap for each corresponding '.d.ts' file. */, 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | "outDir": "./lib" /* Redirect output structure to the directory. */, 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true /* Enable all strict type-checking options. */, 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | 43 | /* Module Resolution Options */ 44 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 45 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 46 | // "typeRoots": [], /* List of folders to include type definitions from. */ 47 | // "types": [], /* Type declaration files to be included in compilation. */ 48 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 49 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 50 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 51 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 52 | 53 | /* Source Map Options */ 54 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 55 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 56 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 57 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 58 | 59 | /* Experimental Options */ 60 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 61 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 62 | 63 | /* Advanced Options */ 64 | "skipLibCheck": true /* Skip type checking of declaration files. */, 65 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 66 | }, 67 | "include": ["src/**/*.ts", "src/__tests__/**/*.tsx"] 68 | } 69 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es2019" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 8 | "module": "es2020" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 9 | // "lib": [], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */, 13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | // "outDir": "./", /* Redirect output structure to the directory. */ 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true /* Enable all strict type-checking options. */, 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | 43 | /* Module Resolution Options */ 44 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 45 | "baseUrl": "./" /* Base directory to resolve non-absolute module names. */, 46 | "paths": { 47 | "react-wc": ["../"] 48 | }, 49 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 50 | // "typeRoots": [], /* List of folders to include type definitions from. */ 51 | // "types": [], /* Type declaration files to be included in compilation. */ 52 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 53 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 54 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 55 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 56 | 57 | /* Source Map Options */ 58 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 61 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 62 | 63 | /* Experimental Options */ 64 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 65 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 66 | 67 | /* Advanced Options */ 68 | "skipLibCheck": true /* Skip type checking of declaration files. */, 69 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/wc.ts: -------------------------------------------------------------------------------- 1 | import React, { useContext, useLayoutEffect, useRef } from "react"; 2 | import { 3 | customElementBaseClassTemplate, 4 | CustomElementClass, 5 | } from "./customElementClassTemplate"; 6 | import { HtmlComponentProps } from "./HtmlComponentProps"; 7 | import { makeChildren } from "./makeChildren"; 8 | import { parseTemplate } from "./parseTemplate"; 9 | import { ServerRenderingContext } from "./ssr"; 10 | 11 | export type WcOptions = { 12 | /** 13 | * Name of custom element to create. 14 | */ 15 | name: string; 16 | /** 17 | * Contents of shadow dom. 18 | */ 19 | shadowHtml: string; 20 | /** 21 | * Name of slots to accept. 22 | */ 23 | slots?: readonly SlotName[]; 24 | /** 25 | * Whether to emit Declarative Shadow DOM support. 26 | */ 27 | declarativeShadowDOM?: boolean; 28 | /** 29 | * Whether to enable classic SSR support. 30 | */ 31 | classicSSR?: boolean; 32 | }; 33 | 34 | const clientDetected = 35 | typeof window !== "undefined" && !!window.HTMLTemplateElement; 36 | 37 | const getCustomElementClass = new Function( 38 | "template", 39 | `const E = ${customElementBaseClassTemplate}; E.template = template; return E` 40 | ) as (template: DocumentFragment) => CustomElementClass; 41 | 42 | /** 43 | * Create a React component from given HTML string and list of slot names. 44 | */ 45 | export function wc({ 46 | name, 47 | shadowHtml, 48 | slots, 49 | declarativeShadowDOM, 50 | classicSSR, 51 | }: WcOptions): React.FunctionComponent> { 52 | let Elm: CustomElementClass | undefined; 53 | let ssrInitialized = false; 54 | const emitDeclarativeShadowDOM = declarativeShadowDOM && !clientDetected; 55 | return (props: React.PropsWithChildren>) => { 56 | if (clientDetected && !Elm) { 57 | const template = parseTemplate(shadowHtml); 58 | 59 | const registered = window.customElements.get(name); 60 | if (registered) { 61 | // this may happen when the component is loaded by something like fast refresh. 62 | registered.template = template; 63 | Elm = registered; 64 | document.querySelectorAll(name).forEach((elm) => { 65 | (elm as any).refresh(); 66 | }); 67 | } else { 68 | Elm = getCustomElementClass(template); 69 | window.customElements.define(name, Elm); 70 | } 71 | } 72 | if (classicSSR && !clientDetected) { 73 | const collector = useContext(ServerRenderingContext); 74 | if (collector && !ssrInitialized) { 75 | collector.addScriptForCustomElement(name, shadowHtml); 76 | } 77 | ssrInitialized = true; 78 | } 79 | const childrenFromProps = makeChildren(props, slots); 80 | const children = emitDeclarativeShadowDOM 81 | ? React.createElement( 82 | React.Fragment, 83 | {}, 84 | React.createElement("template", { 85 | shadowroot: "open", 86 | dangerouslySetInnerHTML: { __html: shadowHtml }, 87 | }), 88 | childrenFromProps 89 | ) 90 | : childrenFromProps; 91 | return React.createElement(name, {}, children); 92 | }; 93 | } 94 | 95 | export type WcIntrinsicOptions< 96 | SlotName extends string, 97 | ElementName extends string 98 | > = { 99 | /** 100 | * Name of element to be rendered by this component. 101 | */ 102 | element: ElementName; 103 | /** 104 | * Contents of shadow dom. 105 | */ 106 | shadowHtml: string; 107 | /** 108 | * Name of slots to accept. 109 | */ 110 | slots?: readonly SlotName[]; 111 | /** 112 | * Whether to emit Declarative Shadow DOM support. 113 | */ 114 | declarativeShadowDOM?: boolean; 115 | }; 116 | 117 | /** 118 | * Create a React Element which renders given kind of element. 119 | */ 120 | export function wcIntrinsic< 121 | SlotName extends string, 122 | ElementName extends keyof JSX.IntrinsicElements 123 | >({ 124 | element, 125 | shadowHtml, 126 | slots, 127 | declarativeShadowDOM, 128 | }: WcIntrinsicOptions): React.FunctionComponent< 129 | JSX.IntrinsicElements[ElementName] & HtmlComponentProps 130 | > { 131 | let template: DocumentFragment | undefined; 132 | const emitDeclarativeShadowDOM = declarativeShadowDOM && !clientDetected; 133 | return ( 134 | props: React.PropsWithChildren< 135 | JSX.IntrinsicElements[ElementName] & HtmlComponentProps 136 | > 137 | ) => { 138 | if (clientDetected && !template) { 139 | template = parseTemplate(shadowHtml); 140 | } 141 | const children = makeChildren(props, slots); 142 | const declarativeShadowDOMChildren = 143 | emitDeclarativeShadowDOM && 144 | React.createElement("template", { 145 | shadowroot: "open", 146 | dangerouslySetInnerHTML: { __html: shadowHtml }, 147 | }); 148 | 149 | const elementRef = useRef(null); 150 | 151 | if (clientDetected) { 152 | // `template` is initialized when clientDetected 153 | const t = template!; 154 | useLayoutEffect(() => { 155 | /* istanbul ignore else */ 156 | if (elementRef.current) { 157 | elementRef.current 158 | .attachShadow({ mode: "open" }) 159 | .appendChild(t.cloneNode(true)); 160 | } 161 | }, []); 162 | } 163 | 164 | const renderedProps: any = { ...props }; 165 | if (slots) { 166 | for (const slot of slots) { 167 | renderedProps[slot] = undefined; 168 | } 169 | } 170 | renderedProps.ref = elementRef; 171 | 172 | return declarativeShadowDOMChildren 173 | ? React.createElement( 174 | element, 175 | renderedProps, 176 | declarativeShadowDOMChildren, 177 | children 178 | ) 179 | : React.createElement(element, renderedProps, children); 180 | }; 181 | } 182 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // All imported modules in your tests should be mocked automatically 6 | // automock: false, 7 | 8 | // Stop running tests after `n` failures 9 | // bail: 0, 10 | 11 | // The directory where Jest should store its cached dependency information 12 | // cacheDirectory: "/private/var/folders/gn/tglh_1gn0w501pf81ml6skw40000gp/T/jest_dy", 13 | 14 | // Automatically clear mock calls and instances between every test 15 | clearMocks: true, 16 | 17 | // Indicates whether the coverage information should be collected while executing the test 18 | collectCoverage: true, 19 | 20 | // An array of glob patterns indicating a set of files for which coverage information should be collected 21 | // collectCoverageFrom: undefined, 22 | 23 | // The directory where Jest should output its coverage files 24 | coverageDirectory: "coverage", 25 | 26 | // An array of regexp pattern strings used to skip coverage collection 27 | // coveragePathIgnorePatterns: [ 28 | // "/node_modules/" 29 | // ], 30 | 31 | // Indicates which provider should be used to instrument code for coverage 32 | coverageProvider: "babel", 33 | 34 | // A list of reporter names that Jest uses when writing coverage reports 35 | // coverageReporters: [ 36 | // "json", 37 | // "text", 38 | // "lcov", 39 | // "clover" 40 | // ], 41 | 42 | // An object that configures minimum threshold enforcement for coverage results 43 | // coverageThreshold: undefined, 44 | 45 | // A path to a custom dependency extractor 46 | // dependencyExtractor: undefined, 47 | 48 | // Make calling deprecated APIs throw helpful error messages 49 | // errorOnDeprecated: false, 50 | 51 | // Force coverage collection from ignored files using an array of glob patterns 52 | // forceCoverageMatch: [], 53 | 54 | // A path to a module which exports an async function that is triggered once before all test suites 55 | // globalSetup: undefined, 56 | 57 | // A path to a module which exports an async function that is triggered once after all test suites 58 | // globalTeardown: undefined, 59 | 60 | // A set of global variables that need to be available in all test environments 61 | // globals: {}, 62 | 63 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 64 | // maxWorkers: "50%", 65 | 66 | // An array of directory names to be searched recursively up from the requiring module's location 67 | // moduleDirectories: [ 68 | // "node_modules" 69 | // ], 70 | 71 | // An array of file extensions your modules use 72 | // moduleFileExtensions: [ 73 | // "js", 74 | // "json", 75 | // "jsx", 76 | // "ts", 77 | // "tsx", 78 | // "node" 79 | // ], 80 | 81 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 82 | // moduleNameMapper: {}, 83 | 84 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 85 | // modulePathIgnorePatterns: [], 86 | 87 | // Activates notifications for test results 88 | // notify: false, 89 | 90 | // An enum that specifies notification mode. Requires { notify: true } 91 | // notifyMode: "failure-change", 92 | 93 | // A preset that is used as a base for Jest's configuration 94 | // preset: undefined, 95 | 96 | // Run tests from one or more projects 97 | // projects: undefined, 98 | 99 | // Use this configuration option to add custom reporters to Jest 100 | // reporters: undefined, 101 | 102 | // Automatically reset mock state between every test 103 | // resetMocks: false, 104 | 105 | // Reset the module registry before running each individual test 106 | // resetModules: false, 107 | 108 | // A path to a custom resolver 109 | // resolver: undefined, 110 | 111 | // Automatically restore mock state between every test 112 | // restoreMocks: false, 113 | 114 | // The root directory that Jest should scan for tests and modules within 115 | // rootDir: undefined, 116 | 117 | // A list of paths to directories that Jest should use to search for files in 118 | roots: [ 119 | "/src" 120 | ], 121 | 122 | // Allows you to use a custom runner instead of Jest's default test runner 123 | // runner: "jest-runner", 124 | 125 | // The paths to modules that run some code to configure or set up the testing environment before each test 126 | // setupFiles: [], 127 | 128 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 129 | // setupFilesAfterEnv: [], 130 | 131 | // The number of seconds after which a test is considered as slow and reported as such in the results. 132 | // slowTestThreshold: 5, 133 | 134 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 135 | // snapshotSerializers: [], 136 | 137 | // The test environment that will be used for testing 138 | // testEnvironment: "jest-environment-jsdom", 139 | 140 | // Options that will be passed to the testEnvironment 141 | // testEnvironmentOptions: {}, 142 | 143 | // Adds a location field to test results 144 | // testLocationInResults: false, 145 | 146 | // The glob patterns Jest uses to detect test files 147 | // testMatch: [ 148 | // "**/__tests__/**/*.[jt]s?(x)", 149 | // "**/?(*.)+(spec|test).[tj]s?(x)" 150 | // ], 151 | 152 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 153 | // testPathIgnorePatterns: [ 154 | // "/node_modules/" 155 | // ], 156 | 157 | // The regexp pattern or array of patterns that Jest uses to detect test files 158 | // testRegex: [], 159 | 160 | // This option allows the use of a custom results processor 161 | // testResultsProcessor: undefined, 162 | 163 | // This option allows use of a custom test runner 164 | // testRunner: "jasmine2", 165 | 166 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 167 | // testURL: "http://localhost", 168 | 169 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 170 | // timers: "real", 171 | 172 | // A map from regular expressions to paths to transformers 173 | // transform: undefined, 174 | 175 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 176 | // transformIgnorePatterns: [ 177 | // "/node_modules/", 178 | // "\\.pnp\\.[^\\/]+$" 179 | // ], 180 | 181 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 182 | // unmockedModulePathPatterns: undefined, 183 | 184 | // Indicates whether each individual test should be reported during the run 185 | // verbose: undefined, 186 | 187 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 188 | // watchPathIgnorePatterns: [], 189 | 190 | // Whether to use watchman for file crawling 191 | // watchman: true, 192 | }; 193 | -------------------------------------------------------------------------------- /src/__tests__/wc.tsx: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | import { render } from "@testing-library/react"; 3 | import React, { Fragment } from "react"; 4 | import { wc } from ".."; 5 | 6 | describe("wc", () => { 7 | describe("basic", () => { 8 | it("no slot", () => { 9 | const Hello = wc({ 10 | shadowHtml: ` 11 | 16 |
Hello
17 | `, 18 | slots: [], 19 | name: "wc-test-1", 20 | }); 21 | 22 | render(); 23 | 24 | const el = document.getElementsByTagName("wc-test-1")[0]; 25 | 26 | expect(el.shadowRoot?.innerHTML).toMatchSnapshot(); 27 | expect(el.innerHTML).toMatchSnapshot(); 28 | }); 29 | it("one slot", () => { 30 | const Hello = wc({ 31 | shadowHtml: ` 32 | 37 |
38 | `, 39 | slots: [], 40 | name: "wc-test-foo", 41 | }); 42 | 43 | render( 44 | 45 | Foobar 46 | 47 | ); 48 | 49 | const el = document.getElementsByTagName("wc-test-foo")[0]; 50 | 51 | expect(el.shadowRoot?.innerHTML).toMatchSnapshot(); 52 | expect(el.innerHTML).toMatchSnapshot(); 53 | }); 54 | it("multiple slots", () => { 55 | const Hello = wc({ 56 | shadowHtml: ` 57 | 62 |
63 |
64 |
65 | `, 66 | slots: ["header", "footer"], 67 | name: "wc-layout", 68 | }); 69 | 70 | render( 71 | head!} footer="foo!"> 72 | Foobar 73 | 74 | ); 75 | 76 | const el = document.getElementsByTagName("wc-layout")[0]; 77 | 78 | expect(el.shadowRoot?.innerHTML).toMatchSnapshot(); 79 | expect(el.innerHTML).toMatchSnapshot(); 80 | }); 81 | }); 82 | describe("Named slot values", () => { 83 | const Hello = wc({ 84 | shadowHtml: ` 85 | 90 | 91 | `, 92 | slots: ["child"], 93 | name: "wc-test-3", 94 | }); 95 | 96 | it("text", () => { 97 | render(); 98 | 99 | const el = document.getElementsByTagName("wc-test-3")[0]; 100 | 101 | expect(el.innerHTML).toMatchSnapshot(); 102 | }); 103 | 104 | it("number", () => { 105 | render(); 106 | 107 | const el = document.getElementsByTagName("wc-test-3")[0]; 108 | 109 | expect(el.innerHTML).toMatchSnapshot(); 110 | }); 111 | 112 | it("boolean", () => { 113 | render(); 114 | 115 | const el = document.getElementsByTagName("wc-test-3")[0]; 116 | 117 | expect(el.innerHTML).toMatchSnapshot(); 118 | }); 119 | 120 | it("null", () => { 121 | render(); 122 | 123 | const el = document.getElementsByTagName("wc-test-3")[0]; 124 | 125 | expect(el.innerHTML).toMatchSnapshot(); 126 | }); 127 | 128 | it("undefined", () => { 129 | render(); 130 | 131 | const el = document.getElementsByTagName("wc-test-3")[0]; 132 | 133 | expect(el.innerHTML).toMatchSnapshot(); 134 | }); 135 | 136 | it("intrinsic element", () => { 137 | render(Hello} />); 138 | 139 | const el = document.getElementsByTagName("wc-test-3")[0]; 140 | 141 | expect(el.innerHTML).toMatchSnapshot(); 142 | }); 143 | 144 | it("function component", () => { 145 | const Fc: React.FC<{ val: string }> = ({ val }) => val is {val}; 146 | render(} />); 147 | 148 | const el = document.getElementsByTagName("wc-test-3")[0]; 149 | 150 | expect(el.innerHTML).toMatchSnapshot(); 151 | }); 152 | 153 | it("nested function component", () => { 154 | const Fc: React.FC<{ val: string }> = ({ val }) => val is {val}; 155 | const Fc2: React.FC<{ val: string }> = ({ val }) => ( 156 | 157 | ); 158 | render(} />); 159 | 160 | const el = document.getElementsByTagName("wc-test-3")[0]; 161 | 162 | expect(el.innerHTML).toMatchSnapshot(); 163 | }); 164 | 165 | it("reused function component", () => { 166 | const Fc: React.FC<{ val: string }> = ({ val, children }) => 167 | children ? {children} : val is {val}; 168 | const Fc2: React.FC = () => ( 169 | 170 | 171 | 172 | ); 173 | render(} />); 174 | 175 | const el = document.getElementsByTagName("wc-test-3")[0]; 176 | 177 | expect(el.innerHTML).toMatchSnapshot(); 178 | }); 179 | 180 | it("class component", () => { 181 | class Cc extends React.Component<{ val: string }> { 182 | render() { 183 | return val is {this.props.val}; 184 | } 185 | } 186 | render(} />); 187 | 188 | const el = document.getElementsByTagName("wc-test-3")[0]; 189 | 190 | expect(el.innerHTML).toMatchSnapshot(); 191 | }); 192 | 193 | it("nested class component", () => { 194 | class Cc extends React.Component<{ val: string }> { 195 | render() { 196 | return val is {this.props.val}; 197 | } 198 | } 199 | class Cc2 extends React.Component<{ val: string }> { 200 | render() { 201 | const val = this.props.val; 202 | return ; 203 | } 204 | } 205 | render(} />); 206 | 207 | const el = document.getElementsByTagName("wc-test-3")[0]; 208 | 209 | expect(el.innerHTML).toMatchSnapshot(); 210 | }); 211 | it("fragment", () => { 212 | const Fc: React.FC<{ val: string }> = ({ val }) => val is {val}; 213 | class Cc extends React.Component<{ val: string }> { 214 | render() { 215 | return val is {this.props.val}; 216 | } 217 | } 218 | 219 | render( 220 | 223 | Hi 224 | Hello 225 | 226 | 227 | 228 | } 229 | /> 230 | ); 231 | 232 | const el = document.getElementsByTagName("wc-test-3")[0]; 233 | 234 | expect(el.innerHTML).toMatchSnapshot(); 235 | }); 236 | it("array", () => { 237 | const Fc: React.FC<{ val: string }> = ({ val }) => val is {val}; 238 | class Cc extends React.Component<{ val: string }> { 239 | render() { 240 | return val is {this.props.val}; 241 | } 242 | } 243 | 244 | render( 245 | Hello, 248 | , 249 | , 250 | ]} 251 | /> 252 | ); 253 | 254 | const el = document.getElementsByTagName("wc-test-3")[0]; 255 | 256 | expect(el.innerHTML).toMatchSnapshot(); 257 | }); 258 | }); 259 | }); 260 | -------------------------------------------------------------------------------- /src/__tests__/html.tsx: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | import { render } from "@testing-library/react"; 3 | import React, { Fragment } from "react"; 4 | import { html, slot } from ".."; 5 | 6 | describe("html", () => { 7 | describe("basic", () => { 8 | it("no slot", () => { 9 | const Hello = html` 10 | 15 |
Hello
16 | `; 17 | 18 | render(); 19 | 20 | const el = document.getElementsByTagName(Hello.elementName)[0]; 21 | 22 | expect(el.shadowRoot?.innerHTML).toMatchSnapshot(); 23 | expect(el.innerHTML).toMatchSnapshot(); 24 | }); 25 | it("one slot", () => { 26 | const Hello = html` 27 | 32 |
${slot()}
33 | `; 34 | 35 | render( 36 | 37 | Foobar 38 | 39 | ); 40 | 41 | const el = document.getElementsByTagName(Hello.elementName)[0]; 42 | 43 | expect(el.shadowRoot?.innerHTML).toMatchSnapshot(); 44 | expect(el.innerHTML).toMatchSnapshot(); 45 | }); 46 | it("multiple slots", () => { 47 | const Hello = html` 48 | 53 |
${slot("header")}
54 |
${slot()}
55 |
${slot("footer")}
56 | `; 57 | 58 | render( 59 | head!} footer="foo!"> 60 | Foobar 61 | 62 | ); 63 | 64 | const el = document.getElementsByTagName(Hello.elementName)[0]; 65 | 66 | expect(el.shadowRoot?.innerHTML).toMatchSnapshot(); 67 | expect(el.innerHTML).toMatchSnapshot(); 68 | }); 69 | }); 70 | describe("Named slot values", () => { 71 | const Hello = html` 72 | 77 | ${slot("child")} 78 | `; 79 | 80 | it("text", () => { 81 | render(); 82 | 83 | const el = document.getElementsByTagName(Hello.elementName)[0]; 84 | 85 | expect(el.innerHTML).toMatchSnapshot(); 86 | }); 87 | 88 | it("number", () => { 89 | render(); 90 | 91 | const el = document.getElementsByTagName(Hello.elementName)[0]; 92 | 93 | expect(el.innerHTML).toMatchSnapshot(); 94 | }); 95 | 96 | it("boolean", () => { 97 | render(); 98 | 99 | const el = document.getElementsByTagName(Hello.elementName)[0]; 100 | 101 | expect(el.innerHTML).toMatchSnapshot(); 102 | }); 103 | 104 | it("null", () => { 105 | render(); 106 | 107 | const el = document.getElementsByTagName(Hello.elementName)[0]; 108 | 109 | expect(el.innerHTML).toMatchSnapshot(); 110 | }); 111 | 112 | it("undefined", () => { 113 | render(); 114 | 115 | const el = document.getElementsByTagName(Hello.elementName)[0]; 116 | 117 | expect(el.innerHTML).toMatchSnapshot(); 118 | }); 119 | 120 | it("intrinsic element", () => { 121 | render(Hello} />); 122 | 123 | const el = document.getElementsByTagName(Hello.elementName)[0]; 124 | 125 | expect(el.innerHTML).toMatchSnapshot(); 126 | }); 127 | 128 | it("function component", () => { 129 | const Fc: React.FC<{ val: string }> = ({ val }) => val is {val}; 130 | render(} />); 131 | 132 | const el = document.getElementsByTagName(Hello.elementName)[0]; 133 | 134 | expect(el.innerHTML).toMatchSnapshot(); 135 | }); 136 | 137 | it("nested function component", () => { 138 | const Fc: React.FC<{ val: string }> = ({ val }) => val is {val}; 139 | const Fc2: React.FC<{ val: string }> = ({ val }) => ( 140 | 141 | ); 142 | render(} />); 143 | 144 | const el = document.getElementsByTagName(Hello.elementName)[0]; 145 | 146 | expect(el.innerHTML).toMatchSnapshot(); 147 | }); 148 | 149 | it("reused function component", () => { 150 | const Fc: React.FC<{ val: string }> = ({ val, children }) => 151 | children ? {children} : val is {val}; 152 | const Fc2: React.FC = () => ( 153 | 154 | 155 | 156 | ); 157 | render(} />); 158 | 159 | const el = document.getElementsByTagName(Hello.elementName)[0]; 160 | 161 | expect(el.innerHTML).toMatchSnapshot(); 162 | }); 163 | 164 | it("class component", () => { 165 | class Cc extends React.Component<{ val: string }> { 166 | render() { 167 | return val is {this.props.val}; 168 | } 169 | } 170 | render(} />); 171 | 172 | const el = document.getElementsByTagName(Hello.elementName)[0]; 173 | 174 | expect(el.innerHTML).toMatchSnapshot(); 175 | }); 176 | 177 | it("nested class component", () => { 178 | class Cc extends React.Component<{ val: string }> { 179 | render() { 180 | return val is {this.props.val}; 181 | } 182 | } 183 | class Cc2 extends React.Component<{ val: string }> { 184 | render() { 185 | const val = this.props.val; 186 | return ; 187 | } 188 | } 189 | render(} />); 190 | 191 | const el = document.getElementsByTagName(Hello.elementName)[0]; 192 | 193 | expect(el.innerHTML).toMatchSnapshot(); 194 | }); 195 | it("fragment", () => { 196 | const Fc: React.FC<{ val: string }> = ({ val }) => val is {val}; 197 | class Cc extends React.Component<{ val: string }> { 198 | render() { 199 | return val is {this.props.val}; 200 | } 201 | } 202 | 203 | render( 204 | 207 | Hi 208 | Hello 209 | 210 | 211 | 212 | } 213 | /> 214 | ); 215 | 216 | const el = document.getElementsByTagName(Hello.elementName)[0]; 217 | 218 | expect(el.innerHTML).toMatchSnapshot(); 219 | }); 220 | it("array", () => { 221 | const Fc: React.FC<{ val: string }> = ({ val }) => val is {val}; 222 | class Cc extends React.Component<{ val: string }> { 223 | render() { 224 | return val is {this.props.val}; 225 | } 226 | } 227 | 228 | render( 229 | Hello, 232 | , 233 | , 234 | ]} 235 | /> 236 | ); 237 | 238 | const el = document.getElementsByTagName(Hello.elementName)[0]; 239 | 240 | expect(el.innerHTML).toMatchSnapshot(); 241 | }); 242 | }); 243 | describe("interpolation", () => { 244 | it("String interpolation", () => { 245 | const className = "foobar"; 246 | const Hello = html` 247 | 252 |
${slot()}
253 | `; 254 | 255 | render( 256 | 257 | Foobar 258 | 259 | ); 260 | 261 | const el = document.getElementsByTagName(Hello.elementName)[0]; 262 | 263 | expect(el.shadowRoot?.innerHTML).toMatchSnapshot(); 264 | expect(el.innerHTML).toMatchSnapshot(); 265 | }); 266 | }); 267 | describe("escape", () => { 268 | it("escaping interpolated string", () => { 269 | const Hello = html` 270 | 275 |
" \'&!'}">${slot()}
276 | ${"Hello, world!"} 277 | `; 278 | 279 | render( 280 | 281 | Foobar 282 | 283 | ); 284 | 285 | const el = document.getElementsByTagName(Hello.elementName)[0]; 286 | 287 | expect(el.shadowRoot?.innerHTML).toMatchSnapshot(); 288 | }); 289 | it("escaping slot name", () => { 290 | const Hello = html` 291 | 296 |
${slot('""')}
297 | `; 298 | 299 | render( 300 | "': Foobar, 303 | }} 304 | /> 305 | ); 306 | 307 | const el = document.getElementsByTagName(Hello.elementName)[0]; 308 | 309 | expect(el.shadowRoot?.innerHTML).toMatchSnapshot(); 310 | expect(el.innerHTML).toMatchSnapshot(); 311 | }); 312 | }); 313 | }); 314 | --------------------------------------------------------------------------------