├── .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 | setCounter((c) => c + 1)}>+1
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 | setCounter((c) => c + 1)}>+1
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 | setCounter((c) => c + 1)}>+1
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 | setMode(e.currentTarget.value as any)}
17 | value={mode}
18 | >
19 | {modes.map((value) => (
20 | {value}
21 | ))}
22 |
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 |
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 |
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 |
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 |
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 | "
5 |
10 |
11 | Hi "
12 | `;
13 |
14 | exports[`declarativeShadowDOM wc one slot 1`] = `
15 | "
16 |
21 |
22 | Foobar "
23 | `;
24 |
25 | exports[`declarativeShadowDOM wcIntrinsic one slot 1`] = `
26 | "
27 |
32 |
33 | Foobar "
34 | `;
35 |
36 | exports[`declarativeShadowDOM wcIntrinsic two slots 1`] = `
37 | ""
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`] = `"Hello val is foobar val 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`] = `"Hi Hello val is foobar val 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 | setCounter((c) => c + 1)}>+1
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 | +1
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`] = `"Hello val is foobar val 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`] = `"Hi Hello val is foobar val 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 |
54 | ${slot()}
55 |
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 |
--------------------------------------------------------------------------------