├── .babelrc
├── .env.example
├── .github
├── CODEOWNERS
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
├── PULL_REQUEST_TEMPLATE.md
├── dependabot.yml
└── workflows
│ ├── dependabot.yml
│ └── nodejs.yml
├── .gitignore
├── .gitpod.yml
├── .husky
├── commit-msg
└── pre-commit
├── .nvmrc
├── .prettierrc.json
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── assets
├── logo.png
└── logo.xd
├── config
├── mocks
│ ├── DOMRect.ts
│ ├── Range.ts
│ └── ResizeObserver.ts
└── setupTests.ts
├── demo
├── Makefile
├── package.json
├── public
│ └── index.html
├── src
│ ├── App.tsx
│ ├── Clock.tsx
│ ├── ScrollList.tsx
│ ├── clock.css
│ ├── index.tsx
│ ├── react-app-env.d.ts
│ └── styles.css
└── tsconfig.json
├── eslint.config.mjs
├── package.json
├── pnpm-lock.yaml
├── src
├── __mocks__.ts
├── __tests__
│ └── index.test.ts
├── components
│ ├── Element.tsx
│ ├── Frame.tsx
│ ├── IFrame.tsx
│ ├── Mirror.tsx
│ ├── Reflection.tsx
│ ├── Styles.tsx
│ ├── Window.tsx
│ └── __tests__
│ │ ├── Element.test.tsx
│ │ ├── Frame.test.tsx
│ │ ├── Mirror.test.tsx
│ │ ├── Reflection.test.tsx
│ │ └── Window.test.tsx
├── hooks
│ ├── __tests__
│ │ ├── useDimensions.test.ts
│ │ ├── useMirror.test.ts
│ │ ├── useObserver.test.ts
│ │ ├── useRef.test.ts
│ │ ├── useRenderTrigger.test.ts
│ │ └── useWindow.test.ts
│ ├── useDimensions.ts
│ ├── useEventHandlers.ts
│ ├── useMirror.tsx
│ ├── useObserver.ts
│ ├── usePortal.ts
│ ├── useRef.ts
│ ├── useRenderTrigger.ts
│ └── useWindow.ts
├── index.ts
└── utils
│ ├── dom.ts
│ └── index.ts
├── tsconfig.json
├── tsconfig.prod.json
└── webpack.config.ts
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["@babel/typescript"]
3 | }
4 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | NPM_TOKEN='https://github.com/semantic-release/npm#environment-variables'
2 | GH_TOKEN='https://github.com/semantic-release/github#environment-variables'
3 | refreshToken='https://github.com/GabrielDuarteM/semantic-release-chrome#chrome-webstore-authentication'
4 |
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/iamogbz/react-mirror/9e9de1f9dd31bfdc8505820dfa58591a09a4bf09/.github/CODEOWNERS
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: [iamogbz]
4 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 |
5 | ---
6 |
7 | **Describe the bug**
8 | A clear and concise description of what the bug is.
9 |
10 | **To Reproduce**
11 | Steps to reproduce the behavior:
12 | 1. Go to '...'
13 | 2. Click on '....'
14 | 3. Scroll down to '....'
15 | 4. See error
16 |
17 | **Expected behavior**
18 | A clear and concise description of what you expected to happen.
19 |
20 | **Screenshots**
21 | If applicable, add screenshots to help explain your problem.
22 |
23 | **Desktop (please complete the following information):**
24 | - OS: [e.g. iOS]
25 | - Browser [e.g. chrome, safari]
26 | - Version [e.g. 22]
27 |
28 | **Smartphone (please complete the following information):**
29 | - Device: [e.g. iPhone6]
30 | - OS: [e.g. iOS8.1]
31 | - Browser [e.g. stock browser, safari]
32 | - Version [e.g. 22]
33 |
34 | **Additional context**
35 | Add any other context about the problem here.
36 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 |
5 | ---
6 |
7 | **Is your feature request related to a problem? Please describe.**
8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
9 |
10 | **Describe the solution you'd like**
11 | A clear and concise description of what you want to happen.
12 |
13 | **Describe alternatives you've considered**
14 | A clear and concise description of any alternative solutions or features you've considered.
15 |
16 | **Additional context**
17 | Add any other context or screenshots about the feature request here.
18 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | # Description
2 |
3 |
4 | ## Changes
5 |
6 | - Itemised summary
7 | - Of all changes made
8 |
9 | ### Checklist
10 |
11 | - [ ] This PR has updated documentation
12 | - [ ] This PR has sufficient testing
13 |
14 | ### Comments
15 |
16 | - Addition comments for reviewers
17 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: npm
4 | directory: "/"
5 | schedule:
6 | interval: daily
7 | time: "10:00"
8 |
--------------------------------------------------------------------------------
/.github/workflows/dependabot.yml:
--------------------------------------------------------------------------------
1 | name: Dependabot
2 | on: pull_request_target
3 |
4 | jobs:
5 | approve:
6 | if: github.actor == 'dependabot[bot]' || github.actor == 'dependabot-preview[bot]'
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: hmarr/auto-approve-action@v2.0.0
10 | with:
11 | github-token: "${{ secrets.GITHUB_TOKEN }}"
12 | merge:
13 | if: github.actor == 'dependabot[bot]' || github.actor == 'dependabot-preview[bot]'
14 | runs-on: ubuntu-latest
15 | steps:
16 | - uses: actions/github-script@v2
17 | with:
18 | github-token: "${{ secrets.GH_TOKEN }}"
19 | script: |
20 | await github.issues.createComment({
21 | owner: context.payload.repository.owner.login,
22 | repo: context.payload.repository.name,
23 | issue_number: context.payload.pull_request.number,
24 | body: '@dependabot merge'
25 | })
26 |
--------------------------------------------------------------------------------
/.github/workflows/nodejs.yml:
--------------------------------------------------------------------------------
1 | name: Build
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 |
9 | jobs:
10 | build:
11 |
12 | runs-on: ubuntu-latest
13 |
14 | steps:
15 | - uses: actions/checkout@v4
16 | - name: Setup
17 | uses: actions/setup-node@v4
18 | with:
19 | node-version-file: .nvmrc
20 | - uses: pnpm/action-setup@v4.0.0
21 | with:
22 | version: 9
23 | run_install: |
24 | args: [--no-frozen-lockfile]
25 | - name: Typecheck
26 | run: |
27 | pnpm build-types
28 | pnpm link .
29 | pnpm typecheck
30 | - name: Lint
31 | run: |
32 | pnpm lint
33 | - name: Test
34 | env:
35 | CI: true
36 | run: |
37 | pnpm test -- --ci --coverage
38 | - name: Report
39 | if: github.actor != 'dependabot[bot]' && github.actor != 'dependabot-preview[bot]'
40 | env:
41 | COVERALLS_GIT_BRANCH: ${{ github.ref }}
42 | COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }}
43 | run: |
44 | pnpm coveralls
45 | - name: Build
46 | run: |
47 | pnpm build
48 | cd ./demo
49 | make build
50 | cd ../
51 | - name: Deploy
52 | uses: peaceiris/actions-gh-pages@v3
53 | if: github.ref == 'refs/heads/main' && github.actor != 'dependabot[bot]' && github.actor != 'dependabot-preview[bot]'
54 | with:
55 | github_token: ${{ secrets.GH_TOKEN }}
56 | publish_dir: ./demo/build
57 | publish_branch: demo
58 | - name: Release
59 | if: github.ref == 'refs/heads/main' && github.actor != 'dependabot[bot]' && github.actor != 'dependabot-preview[bot]'
60 | env:
61 | GH_TOKEN: ${{ secrets.GH_TOKEN }}
62 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
63 | run: |
64 | pnpm release
65 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .vscode/
2 | artifacts/
3 | built/
4 | lib/
5 | node_modules/
6 | demo/build/
7 | demo/pnpm-lock.yaml
8 |
--------------------------------------------------------------------------------
/.gitpod.yml:
--------------------------------------------------------------------------------
1 | # This configuration file was automatically generated by Gitpod.
2 | # Please adjust to your needs (see https://www.gitpod.io/docs/config-gitpod-file)
3 | # and commit this file to your remote git repository to share the goodness with others.
4 |
5 | tasks:
6 | - init: pnpm install && pnpm run build
7 | vscode:
8 | extensions:
9 | - dbaeumer.vscode-eslint
10 |
--------------------------------------------------------------------------------
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | pnpm commitlint --edit "$1"
5 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | lint-staged
5 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 20.11.1
2 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "bracketSpacing": true,
3 | "semi": true,
4 | "singleQuote": false,
5 | "tabWidth": 2,
6 | "trailingComma": "all"
7 | }
8 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to making participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, sex characteristics, gender identity and expression,
9 | level of experience, education, socio-economic status, nationality, personal
10 | appearance, race, religion, or sexual identity and orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | * Using welcoming and inclusive language
18 | * Being respectful of differing viewpoints and experiences
19 | * Gracefully accepting constructive criticism
20 | * Focusing on what is best for the community
21 | * Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | * The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | * Trolling, insulting/derogatory comments, and personal or political attacks
28 | * Public or private harassment
29 | * Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | * Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies both within project spaces and in public spaces
49 | when an individual is representing the project or its community. Examples of
50 | representing a project or community include using an official project e-mail
51 | address, posting via an official social media account, or acting as an appointed
52 | representative at an online or offline event. Representation of a project may be
53 | further defined and clarified by project maintainers.
54 |
55 | ## Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project team at eogbizi@gmail.com. All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an incident.
62 | Further details of specific enforcement policies may be posted separately.
63 |
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 |
68 | ## Attribution
69 |
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
72 |
73 | [homepage]: https://www.contributor-covenant.org
74 |
75 | For answers to common questions about this code of conduct, see
76 | https://www.contributor-covenant.org/faq
77 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | :tada: Thanks for taking the time to contribute! :tada:
4 |
5 | The following is a set of guidelines for contributing to this [repo](https://github.com/iamogbz/react-mirror).
6 | These are mostly guidelines, not rules. Use your best judgment, and feel free to propose changes to this document.
7 |
8 | #### Table Of Contents
9 |
10 | [Code of Conduct](#code-of-conduct)
11 |
12 | [What should I know before I get started?](#what-should-i-know-before-i-get-started)
13 |
14 | - [GNU Make and Bash](#make-and-bash)
15 |
16 | [How Can I Contribute?](#how-can-i-contribute)
17 |
18 | - [Reporting Bugs](#reporting-bugs)
19 | - [Suggesting Enhancements](#suggesting-enhancements)
20 | - [Your First Code Contribution](#your-first-code-contribution)
21 | - [Pull Requests](#pull-requests)
22 |
23 | [Styleguides](#styleguides)
24 |
25 | - [Commit Messages](#commit-messages)
26 | - [Code Styleguide](#code-styleguide)
27 | ## Code of Conduct
28 |
29 | This project and everyone participating in it is governed by our [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code.
30 |
31 | ## What should I know before I get started?
32 |
33 |
34 | - [Makefiles][makefiles]
35 | - [Bourne Again Shell](bashscript)
36 | - [Typescript][typescript]
37 | - [Jest Test Framework][jest]
38 |
39 | ## How Can I Contribute?
40 |
41 | ### Reporting Bugs
42 |
43 | Create a bug report [issue][bug-report]
44 |
45 | ### Suggesting Enhancements
46 |
47 | Create a feature request [issue][feat-request]
48 |
49 | ### Your First Code Contribution
50 |
51 | #### Local development
52 |
53 | Install dependencies using the package node version.
54 |
55 | ```sh
56 | [react-mirror]$ nvm use
57 | [react-mirror]$ npm i -g pnpm
58 | [react-mirror]$ pnpm i
59 | ```
60 |
61 | ##### Testing
62 |
63 | NPM package source code is located in `./src` with tests in the same folder.
64 |
65 | Add [jest style][jest] tests for new changes.
66 |
67 | ```sh
68 | [react-mirror]$ pnpm test
69 | ```
70 |
71 | ##### Validating
72 |
73 | After [setup](#setup), build changes to NPM package source code, then run `demo` app to validate local dev `react-mirror`.
74 |
75 | ```sh
76 | [react-mirror]$ pnpm build
77 | [react-mirror]$ cd ./demo
78 | [react-mirror/demo]$ make start
79 | ```
80 |
81 | __NOTE__: Use `pnpm build-watch` instead to watch code changes when in active development.
82 |
83 | ### Pull Requests
84 |
85 | After validating your [changes](#validating) and adding [tests](#testing), push commits to a new branch and create a pull request using the provided GitHub template.
86 |
87 | ## Styleguides
88 |
89 | ### Commit Messages
90 |
91 | Package using semantic [commits convention][conventional-commits].
92 |
93 | ```sh
94 | git commit -m "fix: that one bug" -m "implementation details"
95 | ```
96 |
97 | See the link above or [commits](../../commits) in the repo for more examples.
98 |
99 | ### Code Styleguide
100 |
101 | Post [install](#local-development) gives you access to the linter and static [type][typescript] checker.
102 |
103 | ```sh
104 | [react-mirror]$ pnpm lint
105 | [react-mirror]$ pnpm typecheck
106 | ```
107 |
108 |
109 |
110 | [bashscript]: https://www.gnu.org/software/bash/manual/html_node/index.html#Top
111 | [bug-report]: https://github.com/iamogbz/react-mirror/issues/new?assignees=&labels=&projects=&template=bug_report.md
112 | [feat-request]: https://github.com/iamogbz/react-mirror/issues/new?assignees=&labels=&projects=&template=feature_request.md
113 | [conventional-commits]: https://www.conventionalcommits.org/en/v1.0.0/
114 | [makefiles]: https://www.gnu.org/software/make/manual/html_node/Introduction.html
115 | [jest]: https://jestjs.io/
116 | [typescript]: https://www.typescriptlang.org/
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | This is free and unencumbered software released into the public domain.
2 |
3 | Anyone is free to copy, modify, publish, use, compile, sell, or
4 | distribute this software, either in source code form or as a compiled
5 | binary, for any purpose, commercial or non-commercial, and by any
6 | means.
7 |
8 | In jurisdictions that recognize copyright laws, the author or authors
9 | of this software dedicate any and all copyright interest in the
10 | software to the public domain. We make this dedication for the benefit
11 | of the public at large and to the detriment of our heirs and
12 | successors. We intend this dedication to be an overt act of
13 | relinquishment in perpetuity of all present and future rights to this
14 | software under copyright law.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22 | OTHER DEALINGS IN THE SOFTWARE.
23 |
24 | For more information, please refer to
25 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React Mirror
2 |
3 |
4 |
5 | [](https://www.npmjs.com/package/react-mirror)
6 | [](https://app.dependabot.com)
7 | [](https://libraries.io/github/iamogbz/react-mirror)
8 | [](https://github.com/iamogbz/react-mirror/actions)
9 | [](https://coveralls.io/github/iamogbz/react-mirror)
10 |
11 | Create synchronized replicas of a React DOM element
12 |
13 | > Suggested alternative for non react usage [mirror-element](https://github.com/iamogbz/oh-my-wcs/blob/main/components/el-mirror.md)
14 |
15 | ## Usage
16 |
17 | See equivalent uses of the hook and component below.
18 |
19 | ### `useMirror` hook
20 |
21 | ```jsx
22 | import { useMirror } from 'react-mirror';
23 |
24 | function App() {
25 | const [ref, mirror] = useMirror({ className: 'mirror-frame' });
26 | return (
27 | <>
28 |
29 | {mirror}
30 | >
31 | );
32 | }
33 | ```
34 |
35 | ### ` ` component
36 |
37 | ```jsx
38 | import React from 'react';
39 | import { Mirror } from 'react-mirror';
40 |
41 | function App() {
42 | const [reflect, setReflect] = React.useState(null);
43 | return (
44 | <>
45 |
46 |
47 | >
48 | );
49 | }
50 | ```
51 |
52 | ### ` ` component
53 |
54 | You can also render a reflection, with all the styles needed, in a separate window using the magic of [`Portals`](https://reactjs.org/docs/portals.html) 🌀
55 |
56 | ```jsx
57 | import React from 'react';
58 | import { FrameStyles, Reflection, Window } from 'react-mirror';
59 |
60 | function App() {
61 | const [reflect, setReflect] = React.useState(null);
62 | return (
63 | <>
64 |
65 |
66 |
67 |
68 |
69 | >
70 | );
71 | }
72 | ```
73 |
74 | ## Demos
75 |
76 | ### Using Portals
77 |
78 | - [Live Preview](https://ogbizi.com/react-mirror/) ([source](demo))
79 |
80 | ## Alternatives
81 |
82 | - Simple HTML custom [mirror-element](https://github.com/iamogbz/oh-my-wcs/blob/main/components/el-mirror.md)
83 | - [Curious case of independent genesis](https://github.com/Theadd/react-mirror#readme)
84 |
--------------------------------------------------------------------------------
/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/iamogbz/react-mirror/9e9de1f9dd31bfdc8505820dfa58591a09a4bf09/assets/logo.png
--------------------------------------------------------------------------------
/assets/logo.xd:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/iamogbz/react-mirror/9e9de1f9dd31bfdc8505820dfa58591a09a4bf09/assets/logo.xd
--------------------------------------------------------------------------------
/config/mocks/DOMRect.ts:
--------------------------------------------------------------------------------
1 | export default class DOMRect {
2 | bottom = 0;
3 | left = 0;
4 | right = 0;
5 | top = 0;
6 | constructor(
7 | public x = 0,
8 |
9 | public y = 0,
10 |
11 | public width = 0,
12 |
13 | public height = 0,
14 | ) {
15 | this.left = x;
16 | this.top = y;
17 | this.right = x + width;
18 | this.bottom = y + height;
19 | }
20 | }
21 |
22 | Object.assign(window, { DOMRect });
23 |
--------------------------------------------------------------------------------
/config/mocks/Range.ts:
--------------------------------------------------------------------------------
1 | Range.prototype.getBoundingClientRect = () => new DOMRect();
2 | /*
3 | Range.prototype.getClientRects = function () {
4 | const clientRects = [this.getBoundingClientRect()];
5 | return {
6 | item: (i: number) => clientRects[i] ?? null,
7 | length: 1,
8 | [Symbol.iterator]: () => clientRects.values(),
9 | };
10 | };
11 | */
12 |
--------------------------------------------------------------------------------
/config/mocks/ResizeObserver.ts:
--------------------------------------------------------------------------------
1 | export default class ResizeObserver {
2 | observe() {
3 | // do nothing
4 | }
5 | unobserve() {
6 | // do nothing
7 | }
8 | disconnect() {
9 | // do nothing
10 | }
11 | }
12 |
13 | Object.assign(window, { ResizeObserver });
14 |
--------------------------------------------------------------------------------
/config/setupTests.ts:
--------------------------------------------------------------------------------
1 | import "./mocks/DOMRect";
2 | import "./mocks/Range";
3 | import "./mocks/ResizeObserver";
4 |
5 | import { configure } from "@testing-library/react";
6 |
7 | configure({ testIdAttribute: "data-test-id" });
8 |
--------------------------------------------------------------------------------
/demo/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: link
2 | link:
3 | @echo "Linking local 'react-mirror'"
4 | @pnpm link "../" --dir "."
5 |
6 | .PHONY: start
7 | start: link
8 | @../node_modules/.bin/react-scripts start
9 |
10 | .PHONY: build
11 | build: link
12 | @../node_modules/.bin/react-scripts build
13 |
--------------------------------------------------------------------------------
/demo/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "homepage": "https://iamogbz.github.io/react-mirror",
3 | "browserslist": [
4 | ">0.2%",
5 | "not dead",
6 | "not ie <= 11",
7 | "not op_mini all"
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/demo/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 |
10 |
11 |
12 | React Mirror Demo
13 |
14 |
15 |
16 | You need to enable JavaScript to run this app.
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/demo/src/App.tsx:
--------------------------------------------------------------------------------
1 | import "./styles.css";
2 |
3 | import * as React from "react";
4 | import {
5 | FrameStyles,
6 | Reflection,
7 | useDimensions,
8 | useMirror,
9 | useRefs,
10 | Window,
11 | } from "react-mirror";
12 |
13 | import { Clock } from "./Clock";
14 | import { ScrollList } from "./ScrollList";
15 |
16 | export default function App(): JSX.Element {
17 | const [usingPortal, setUsingPortal] = React.useState(false);
18 | const [ref, mirror] = useMirror({ className: "Frame" });
19 | const [target, setTarget] = useRefs(ref);
20 | const dimensions = useDimensions(target);
21 |
22 | return (
23 |
24 |
React Mirror Demo
25 |
setUsingPortal(true)}
29 | >
30 | Start thinking with portals!
31 |
32 |
33 |
34 |
35 |
36 |
Mirror mirror in my dom
37 |
38 |
39 |
40 |
41 | {usingPortal ? (
42 |
setUsingPortal(false)}
49 | >
50 |
51 |
52 |
53 | ) : (
54 | mirror
55 | )}
56 |
57 |
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/demo/src/Clock.tsx:
--------------------------------------------------------------------------------
1 | import "./clock.css";
2 |
3 | import * as React from "react";
4 |
5 | function useCurrent(): Date {
6 | const [date, setDate] = React.useState(new Date());
7 | React.useEffect(() => {
8 | const timeout = setTimeout(() => setDate(new Date()), 300);
9 | return (): void => clearTimeout(timeout);
10 | }, [date, setDate]);
11 | return date;
12 | }
13 |
14 | export function Clock(): JSX.Element {
15 | const date = useCurrent();
16 | const ratios = {
17 | Second: date.getSeconds() / 60,
18 | Minute: date.getMinutes() / 60,
19 | Hour: (date.getHours() % 12) / 12,
20 | };
21 | return (
22 |
23 | {Object.entries(ratios).map(([cls, ratio]) => (
24 |
29 | ))}
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/demo/src/ScrollList.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | export function ScrollList() {
4 | return (
5 |
6 | {Array.from(new Array(10)).map((_, i) => (
7 |
8 | Item {i}
9 |
10 | ))}
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/demo/src/clock.css:
--------------------------------------------------------------------------------
1 | .Clock.Face {
2 | margin: 10px auto;
3 | position: relative;
4 | width: 144px;
5 | height: 144px;
6 | border: solid 4px black;
7 | border-radius: 100%;
8 | }
9 |
10 | .Clock[data-name="MyClock"]::after {
11 | content: "";
12 | position: absolute;
13 | background-color: black;
14 | border-radius: 100%;
15 | width: 10px;
16 | height: 10px;
17 | top: calc(50% - 5px);
18 | left: calc(50% - 5px);
19 | }
20 |
21 | .Clock.Hand {
22 | position: absolute;
23 | width: 6%;
24 | left: 47%;
25 | transform-origin: bottom;
26 | border-radius: 100%;
27 | }
28 |
29 | .Clock.Hand.Hour {
30 | background-color: red;
31 | height: 25%;
32 | top: 25%;
33 | }
34 |
35 | .Clock.Hand.Minute {
36 | background-color: violet;
37 | height: 35%;
38 | top: 15%;
39 | }
40 |
41 | .Clock.Hand.Second {
42 | background-color: orange;
43 | height: 45%;
44 | top: 5%;
45 | }
46 |
--------------------------------------------------------------------------------
/demo/src/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { createRoot } from "react-dom/client";
3 |
4 | import App from "./App";
5 |
6 | const rootElement = document.getElementById("root");
7 | if (rootElement) {
8 | createRoot(rootElement).render( );
9 | }
10 |
--------------------------------------------------------------------------------
/demo/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/demo/src/styles.css:
--------------------------------------------------------------------------------
1 | .App, .Frame {
2 | font-family: sans-serif;
3 | text-align: center;
4 | border: none;
5 | padding: 0;
6 | margin: 0;
7 | }
8 |
9 | .Input, .Button {
10 | padding: 10px 15px;
11 | margin: 10px;
12 | font-size: 1.2em;
13 | border: solid 1px grey;
14 | border-radius: 4px;
15 | }
16 |
17 | .Input:hover {
18 | border-color: dodgerblue;
19 | }
20 |
21 | .Button {
22 | cursor: pointer;
23 | margin: 0 auto;
24 | display: block;
25 | }
26 |
27 | .Demo {
28 | display: flex;
29 | flex-direction: row;
30 | justify-content: center;
31 | }
32 |
--------------------------------------------------------------------------------
/demo/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "compilerOptions": {
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "strict": true,
13 | "forceConsistentCasingInFileNames": true,
14 | "module": "esnext",
15 | "resolveJsonModule": true,
16 | "isolatedModules": true,
17 | "noEmit": true
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import path from "node:path";
2 | import { fileURLToPath } from "node:url";
3 |
4 | import { fixupConfigRules } from "@eslint/compat";
5 | import { FlatCompat } from "@eslint/eslintrc";
6 | import js from "@eslint/js";
7 | import tsParser from "@typescript-eslint/parser";
8 | import simpleImportSort from "eslint-plugin-simple-import-sort";
9 | import globals from "globals";
10 |
11 | const __filename = fileURLToPath(import.meta.url);
12 | const __dirname = path.dirname(__filename);
13 | const compat = new FlatCompat({
14 | baseDirectory: __dirname,
15 | recommendedConfig: js.configs.recommended,
16 | allConfig: js.configs.all,
17 | });
18 |
19 | export default [
20 | {
21 | ignores: [
22 | "**/.circleci/",
23 | "**/artifacts/",
24 | "**/internals/",
25 | "**/server/",
26 | "**/docs/",
27 | "**/node_modules/",
28 | "**/lib/",
29 | "**/built/",
30 | ],
31 | },
32 | ...fixupConfigRules(
33 | compat.extends(
34 | "plugin:@typescript-eslint/recommended",
35 | "plugin:prettier/recommended",
36 | "plugin:react/recommended",
37 | "plugin:react-hooks/recommended",
38 | ),
39 | ),
40 | {
41 | plugins: {
42 | "simple-import-sort": simpleImportSort,
43 | },
44 |
45 | languageOptions: {
46 | globals: {
47 | ...globals.browser,
48 | ...globals.node,
49 | ...globals.jest,
50 | browser: false,
51 | },
52 |
53 | parser: tsParser,
54 | ecmaVersion: 6,
55 | sourceType: "module",
56 |
57 | parserOptions: {
58 | ecmaFeatures: {
59 | jsx: true,
60 | },
61 | },
62 | },
63 |
64 | settings: {
65 | react: {
66 | version: "detect",
67 | },
68 |
69 | "import/resolver": {
70 | node: {
71 | paths: ["src"],
72 | },
73 | },
74 | },
75 |
76 | rules: {
77 | "class-methods-use-this": "off",
78 | "no-console": "warn",
79 |
80 | "no-param-reassign": [
81 | "error",
82 | {
83 | props: false,
84 | },
85 | ],
86 |
87 | "@typescript-eslint/no-unused-vars": [
88 | "warn",
89 | {
90 | argsIgnorePattern: "^_",
91 | },
92 | ],
93 |
94 | "no-trailing-spaces": "error",
95 | "no-use-before-define": "off",
96 |
97 | "@typescript-eslint/no-use-before-define": [
98 | "error",
99 | {
100 | functions: false,
101 | },
102 | ],
103 |
104 | "object-curly-newline": [
105 | "error",
106 | {
107 | multiline: true,
108 | consistent: true,
109 | },
110 | ],
111 |
112 | "object-property-newline": [
113 | "error",
114 | {
115 | allowAllPropertiesOnSameLine: true,
116 | },
117 | ],
118 |
119 | "react/jsx-first-prop-new-line": ["error", "multiline"],
120 | "react/jsx-indent": ["error", 2],
121 | "react/jsx-indent-props": ["error", 2],
122 | "simple-import-sort/imports": "error",
123 | "simple-import-sort/exports": "error",
124 | "prettier/prettier": "error",
125 | },
126 | },
127 | ];
128 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-mirror",
3 | "version": "0.0.1",
4 | "description": "Create synchronized replicas of a React DOM element",
5 | "author": "iamogbz",
6 | "repository": {
7 | "type": "git",
8 | "url": "https://github.com/iamogbz/react-mirror"
9 | },
10 | "bugs": {
11 | "url": "https://github.com/iamogbz/react-mirror/issues"
12 | },
13 | "homepage": "https://github.com/iamogbz/react-mirror#readme",
14 | "license": "Unlicense",
15 | "keywords": [
16 | "mirror",
17 | "react-mirror",
18 | "react",
19 | "react-dom",
20 | "react-portal",
21 | "portal"
22 | ],
23 | "engines": {
24 | "node": ">=8",
25 | "npm": ">=6"
26 | },
27 | "main": "lib/main.js",
28 | "types": "lib/index.d.ts",
29 | "files": [
30 | "lib"
31 | ],
32 | "scripts": {
33 | "lint": "eslint .",
34 | "build": "webpack --mode=production",
35 | "build-watch": "webpack --mode=development --watch",
36 | "build-types": "tsc --project tsconfig.prod.json --emitDeclarationOnly --declaration",
37 | "test": "jest",
38 | "coveralls": "cat ./artifacts/coverage/lcov.info | coveralls",
39 | "typecheck": "tsc --project tsconfig.prod.json --noEmit",
40 | "commit": "git-cz",
41 | "release": "semantic-release"
42 | },
43 | "release": {
44 | "dryRun": false,
45 | "branches": [
46 | "+([0-9])?(.{+([0-9]),x}).x",
47 | "main",
48 | "next",
49 | "next-major",
50 | {
51 | "name": "beta",
52 | "prerelease": true
53 | },
54 | {
55 | "name": "alpha",
56 | "prerelease": true
57 | }
58 | ],
59 | "plugins": [
60 | "@semantic-release/commit-analyzer",
61 | "@semantic-release/release-notes-generator",
62 | "@semantic-release/npm",
63 | "@semantic-release/github"
64 | ]
65 | },
66 | "jest": {
67 | "preset": "ts-jest",
68 | "testEnvironment": "jsdom",
69 | "setupFilesAfterEnv": [
70 | "./config/setupTests.ts"
71 | ],
72 | "moduleDirectories": [
73 | "./src",
74 | "./tests",
75 | "./node_modules"
76 | ],
77 | "testPathIgnorePatterns": [
78 | "./artifacts/",
79 | "./node_modules/"
80 | ],
81 | "testRegex": "(/__tests__/.+(\\.|/)(test|spec))\\.[jt]sx?$",
82 | "coverageDirectory": "./artifacts/coverage"
83 | },
84 | "commitlint": {
85 | "extends": [
86 | "@commitlint/config-conventional"
87 | ]
88 | },
89 | "config": {
90 | "commitizen": {
91 | "path": "cz-conventional-changelog"
92 | }
93 | },
94 | "lint-staged": {
95 | "*.(j|t)s{,x}": [
96 | "pnpm lint",
97 | "pnpm test -- --bail --findRelatedTests"
98 | ]
99 | },
100 | "peerDependencies": {
101 | "react": "^16.0.0 || ^17.0.0 || ^18.0.0",
102 | "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0"
103 | },
104 | "dependencies": {
105 | "specificity": "^1.0.0"
106 | },
107 | "devDependencies": {
108 | "@babel/cli": "^7.25.9",
109 | "@babel/core": "^7.26.0",
110 | "@babel/eslint-parser": "^7.25.9",
111 | "@babel/plugin-syntax-flow": "^7.26.0",
112 | "@babel/plugin-transform-react-jsx": "^7.25.9",
113 | "@babel/preset-typescript": "^7.26.0",
114 | "@commitlint/cli": "^19.6.0",
115 | "@commitlint/config-conventional": "^19.6.0",
116 | "@eslint/compat": "^1.2.3",
117 | "@testing-library/react": "^16.0.1",
118 | "@types/eslint": "^9.6.1",
119 | "@types/eslint-scope": "^3.7.7",
120 | "@types/jest": "^29.5.14",
121 | "@types/node": "^24.0.0",
122 | "@types/react": "^18.3.12",
123 | "@types/react-dom": "^18.3.1",
124 | "@types/react-test-renderer": "^18.3.0",
125 | "@types/webpack": "^5.28.5",
126 | "@types/webpack-node-externals": "^3.0.4",
127 | "@typescript-eslint/eslint-plugin": "^8.16.0",
128 | "@typescript-eslint/parser": "^8.16.0",
129 | "acorn": "^8.14.0",
130 | "autoprefixer": "^10.4.20",
131 | "babel-loader": "^10.0.0",
132 | "babel-plugin-transform-class-properties": "^6.24.1",
133 | "commitizen": "^4.3.1",
134 | "coveralls": "^3.1.1",
135 | "cz-conventional-changelog": "^3.3.0",
136 | "eslint": "^8.57.1",
137 | "eslint-config-airbnb": "^19.0.4",
138 | "eslint-config-prettier": "^10.0.1",
139 | "eslint-plugin-import": "^2.31.0",
140 | "eslint-plugin-jsx-a11y": "^6.10.2",
141 | "eslint-plugin-prettier": "^5.2.1",
142 | "eslint-plugin-react": "^7.37.2",
143 | "eslint-plugin-react-hooks": "^5.0.0",
144 | "eslint-plugin-simple-import-sort": "^12.1.1",
145 | "globals": "^16.0.0",
146 | "husky": "^9.1.7",
147 | "jest": "^29.7.0",
148 | "jest-environment-jsdom": "^30.0.0",
149 | "lint-staged": "^16.0.0",
150 | "postcss": "^8.4.49",
151 | "prettier": "^3.4.1",
152 | "prettier-eslint": "^16.3.0",
153 | "prop-types": "^15.8.1",
154 | "react": "^18.3.1",
155 | "react-dom": "^18.3.1",
156 | "react-scripts": "^5.0.1",
157 | "react-test-renderer": "^18.3.1",
158 | "rxjs": "^7.8.1",
159 | "semantic-release": "^24.2.0",
160 | "ts-jest": "^29.2.5",
161 | "ts-loader": "^9.5.1",
162 | "ts-node": "^10.9.2",
163 | "typescript": "^5.7.2",
164 | "webpack": "^5.96.1",
165 | "webpack-cli": "^6.0.1",
166 | "webpack-compiler-plugin": "^1.1.5",
167 | "webpack-node-externals": "^3.0.0"
168 | }
169 | }
170 |
--------------------------------------------------------------------------------
/src/__mocks__.ts:
--------------------------------------------------------------------------------
1 | export function addDomNode() {
2 | const domNode = document.createElement("div");
3 | document.body.appendChild(domNode);
4 | const node0 = document.createComment("comment node");
5 | document.appendChild(node0);
6 | const node1 = document.createElement("div");
7 | domNode.appendChild(node1);
8 | node1.className = "class1 one";
9 | node1.setAttribute("attr", "[value");
10 | const node2 = document.createElement("input");
11 | node1.appendChild(node2);
12 | node2.className = "class2 two";
13 | node2.value = "input-value";
14 | const node3 = document.createTextNode("text content");
15 | domNode.appendChild(node3);
16 | return domNode;
17 | }
18 |
19 | export function addDomStyles() {
20 | const domStyle = document.createElement("style");
21 | document.head.appendChild(domStyle);
22 | domStyle.innerHTML = `
23 | @charset "utf-8";
24 | @font-face {
25 | font-family: "Open Sans";
26 | }
27 | body, .mirrorFrame:not(:focus) {
28 | font-family: "san-serif";
29 | font-size: 1.2em;
30 | }
31 | :is(::after), ::before {
32 | position: absolute;
33 | }
34 | :where(::slotted(span)) {
35 | border: none;
36 | }
37 | ::after {
38 | content: '';
39 | }
40 | .mirrorFrame::before {
41 | content: 'mock text';
42 | }
43 | .class1.one, .class2.two {
44 | height: 10px;
45 | }
46 | .class2.two {
47 | font-size: 1.3em;
48 | display: block;
49 | width: 40px;
50 | margin: 0 auto;
51 | }
52 | .class3.three::after {
53 | background: red;
54 | width: 5px;
55 | height: 5px;
56 | }
57 | .class1.one[attr^="[val"] .class2.two {
58 | width: 20px;
59 | }
60 | `;
61 | return domStyle;
62 | }
63 |
--------------------------------------------------------------------------------
/src/__tests__/index.test.ts:
--------------------------------------------------------------------------------
1 | describe("Entry", () => {
2 | it("exports expected modules", async () => {
3 | // eslint-disable-next-line @typescript-eslint/no-require-imports
4 | expect(require("../")).toMatchInlineSnapshot(`
5 | {
6 | "Frame": [Function],
7 | "FrameStyles": [Function],
8 | "Mirror": [Function],
9 | "Reflection": [Function],
10 | "Window": [Function],
11 | "useDimensions": [Function],
12 | "useMirror": [Function],
13 | "useRefs": [Function],
14 | }
15 | `);
16 | });
17 | });
18 |
--------------------------------------------------------------------------------
/src/components/Element.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { useRefs } from "../hooks/useRef";
4 | import { syncScroll } from "../utils/dom";
5 |
6 | export type ElementProps =
7 | T extends keyof JSX.IntrinsicElements
8 | ? JSX.IntrinsicElements[T]
9 | : JSX.IntrinsicElements[keyof JSX.IntrinsicElements];
10 |
11 | export type DOMElement = T extends keyof HTMLElementTagNameMap
12 | ? HTMLElementTagNameMap[T]
13 | : never;
14 |
15 | type DOMElementProps = {
16 | tagName: T;
17 | domRef?: React.Ref>;
18 | scroll?: { x: number; y: number };
19 | } & ElementProps;
20 |
21 | export function Element({
22 | children,
23 | domRef,
24 | tagName,
25 | scroll: { x: scrollLeft, y: scrollTop } = { x: 0, y: 0 },
26 | ...props
27 | }: DOMElementProps) {
28 | const [ref, setRef] = useRefs(domRef);
29 |
30 | React.useEffect(
31 | () => syncScroll({ scrollTop, scrollLeft }, ref),
32 | [ref, scrollLeft, scrollTop],
33 | );
34 |
35 | // For attributes that are not always updated when the prop is removed
36 | React.useEffect(
37 | /** Manually reset missing values */ () => {
38 | const valueResetMap = { value: "" };
39 | Object.entries(valueResetMap).forEach(([name, resetValue]) => {
40 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
41 | if (ref) (ref as any)[name] = (props as any)[name] ?? resetValue;
42 | });
43 | },
44 | [ref, props],
45 | );
46 |
47 | return React.createElement(
48 | tagName.toLowerCase(),
49 | { ...props, ref: setRef },
50 | ...(Array.isArray(children) ? children : [children]),
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/src/components/Frame.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { useObserver } from "../hooks/useObserver";
4 | import { getAllStyleRules } from "../utils/dom";
5 | import { IFrame, IFrameProps } from "./IFrame";
6 | import { Style } from "./Styles";
7 |
8 | export type FrameProps = Omit;
9 |
10 | /**
11 | * Used to wrap and isolate reflection from rest of document
12 | */
13 | export function Frame({ children, style, ...frameProps }: FrameProps) {
14 | return (
15 |
24 | );
25 | }
26 |
27 | /**
28 | * Styling used to keep the mirror frame in-sync with document
29 | */
30 | export function FrameStyles() {
31 | return (
32 | <>
33 |
34 |
35 | >
36 | );
37 | }
38 |
39 | /**
40 | * Copy of the current document style sheets to be used in framing
41 | */
42 | function DocumentStyle() {
43 | const [rules, setRules] = React.useState(getAllStyleRules);
44 |
45 | const onUpdate = React.useCallback(
46 | function updateRules() {
47 | const newRules = getAllStyleRules();
48 | if (JSON.stringify(rules) === JSON.stringify(newRules)) return;
49 | setRules(newRules);
50 | },
51 | [rules],
52 | );
53 |
54 | useObserver({
55 | ObserverClass: MutationObserver,
56 | onUpdate,
57 | initOptions: {
58 | attributeFilter: ["class"],
59 | attributes: true,
60 | characterData: true,
61 | childList: true,
62 | subtree: true,
63 | },
64 | target: window.document,
65 | });
66 |
67 | return ;
68 | }
69 |
70 | /**
71 | * https://www.webfx.com/blog/web-design/should-you-reset-your-css
72 | */
73 | function ResetStyle() {
74 | return (
75 | <>
76 |
80 |
81 | >
82 | );
83 | }
84 |
--------------------------------------------------------------------------------
/src/components/IFrame.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { usePortal } from "../hooks/usePortal";
4 | import { useCallbackRef } from "../hooks/useRef";
5 | import { ElementProps } from "./Element";
6 |
7 | export interface IFrameProps extends ElementProps<"iframe"> {
8 | /** Get the iframe content node the react children will be rendered into */
9 | getMountNode?: (_?: Window) => Element | undefined;
10 | }
11 |
12 | export function IFrame({
13 | children,
14 | getMountNode = (window) => window?.document?.body,
15 | ...props
16 | }: IFrameProps) {
17 | const [iframe, ref] = useCallbackRef();
18 | const mountNode = getMountNode(iframe?.contentWindow ?? undefined);
19 | const portal = usePortal({ source: children, target: mountNode });
20 |
21 | return (
22 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/Mirror.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as ReactDOM from "react-dom";
3 |
4 | import { useDimensions } from "../hooks/useDimensions";
5 | import { randomString } from "../utils";
6 | import { Frame, FrameProps } from "./Frame";
7 | import { Reflection } from "./Reflection";
8 |
9 | export interface MirrorProps extends Pick {
10 | frameProps?: FrameProps;
11 | /** Reference to target instance to reflect */
12 | reflect?: React.ReactInstance;
13 | }
14 |
15 | /**
16 | * Create reflection wrapped in frame element
17 | */
18 | export function Mirror({ className, frameProps, reflect }: MirrorProps) {
19 | const { instanceId, real } = React.useMemo(
20 | () => ({
21 | /** Unique identifier of this mirror */
22 | instanceId: randomString(7),
23 | /** Actual DOM node of react element being reflected */
24 | real: getRealNode(reflect),
25 | }),
26 | [reflect],
27 | );
28 |
29 | const { top, bottom, left, right } = useDimensions(real);
30 | return (
31 |
38 |
39 |
40 | );
41 | }
42 |
43 | function getRealNode(instance?: React.ReactInstance) {
44 | try {
45 | // eslint-disable-next-line react/no-find-dom-node
46 | return ReactDOM.findDOMNode(instance) ?? undefined;
47 | } catch (e) {
48 | // eslint-disable-next-line no-console
49 | console.warn(e);
50 | return;
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/components/Reflection.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { useObserver } from "../hooks/useObserver";
4 | import { useRenderTrigger } from "../hooks/useRenderTrigger";
5 | import { spinalToCamelCase } from "../utils";
6 | import {
7 | getAttributes,
8 | getChildren,
9 | getUserActionCustomPseudoClassList,
10 | isText,
11 | } from "../utils/dom";
12 | import { Element } from "./Element";
13 |
14 | /**
15 | * User action events to track and update style
16 | * https://developer.mozilla.org/en-US/docs/Web/Events#event_listing
17 | */
18 | const TRACK_EVENTS = new Set([
19 | "click",
20 | "input",
21 | "keypress",
22 | "keydown",
23 | "keyup",
24 | "mousemove",
25 | "mousedown",
26 | "mouseup",
27 | "scroll",
28 | ] as const);
29 | const OBSERVER_INIT = {
30 | attributes: true,
31 | childList: true,
32 | subtree: false,
33 | characterData: true,
34 | } as const;
35 |
36 | export type ReflectionProps = {
37 | className?: string;
38 | real?: Element | Text;
39 | style?: React.CSSProperties;
40 | };
41 |
42 | export function Reflection({ className, real, style }: ReflectionProps) {
43 | const {
44 | class: classList,
45 | style: styleList,
46 | ...attributes
47 | } = getAttributes(real);
48 | const children = getChildren(real).map((childNode, i) => {
49 | return ;
50 | });
51 | const pseudoClassList = getUserActionCustomPseudoClassList(real);
52 |
53 | // Trigger a rerender
54 | const { rerender: onUpdate } = useRenderTrigger();
55 | // Observe changes on real element
56 | useObserver({
57 | initOptions: OBSERVER_INIT,
58 | ObserverClass: MutationObserver,
59 | onEvents: TRACK_EVENTS,
60 | onUpdate,
61 | target: real,
62 | });
63 |
64 | if (!real) return null;
65 | if (isText(real)) return <>{real.textContent}>;
66 | return (
67 |
75 | {children}
76 |
77 | );
78 | }
79 |
80 | function mergeClassList(...cls: (string | undefined)[]) {
81 | return cls.filter(Boolean).join(" ");
82 | }
83 |
84 | export function mergeStyleProps(
85 | cssProps: React.CSSProperties = {},
86 | inlineCSS = "",
87 | styleDecl?: CSSStyleDeclaration,
88 | ): React.CSSProperties {
89 | return {
90 | ...(styleDecl &&
91 | asCSSProperties(
92 | Array.from(styleDecl),
93 | (prop) => prop,
94 | (prop) => styleDecl.getPropertyValue(prop),
95 | )),
96 | ...asCSSProperties(
97 | inlineCSS.split(";").map((propValue) => propValue.split(":")),
98 | ([prop]) => prop.trim(),
99 | ([, value]) => value?.trim(),
100 | ),
101 | ...cssProps,
102 | };
103 | }
104 |
105 | function asCSSProperties(
106 | items: T[],
107 | getProp: (_: T) => string,
108 | getValue: (_: T) => string | undefined,
109 | ): React.CSSProperties {
110 | return Object.fromEntries(
111 | items
112 | .map((item) => [spinalToCamelCase(getProp(item)), getValue(item)])
113 | .filter(([, value]) => Boolean(value)),
114 | );
115 | }
116 |
--------------------------------------------------------------------------------
/src/components/Styles.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { ElementProps } from "./Element";
4 |
5 | export interface StyleProps extends ElementProps<"style"> {
6 | rules: string[];
7 | }
8 |
9 | export function Style({ rules, ...styleProps }: StyleProps) {
10 | return ;
11 | }
12 |
--------------------------------------------------------------------------------
/src/components/Window.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { usePortal } from "../hooks/usePortal";
4 | import { useWindow, UseWindowProps } from "../hooks/useWindow";
5 |
6 | export type WindowProps = React.PropsWithChildren;
7 |
8 | export function Window({ children, ...windowProps }: WindowProps): JSX.Element {
9 | const portalWindow = useWindow(windowProps);
10 |
11 | const portal = usePortal({
12 | source: children,
13 | target: portalWindow?.document.body,
14 | });
15 |
16 | return <>{portal}>;
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/__tests__/Element.test.tsx:
--------------------------------------------------------------------------------
1 | import { render } from "@testing-library/react";
2 | import * as React from "react";
3 |
4 | import { Element } from "../Element";
5 |
6 | describe("Element", () => {
7 | it("creates expected element", () => {
8 | const ref = React.createRef();
9 | expect(ref.current).toBeNull();
10 | render( );
11 | expect(ref.current).toMatchInlineSnapshot(`
12 |
15 | `);
16 | });
17 |
18 | it("creates expected element with array of children", () => {
19 | const subject = render(
20 |
21 | {["childA", "childB"]}
22 | ,
23 | );
24 | expect(subject.getByTestId("testId")).toMatchInlineSnapshot(`
25 |
28 | childA
29 | childB
30 |
31 | `);
32 | });
33 |
34 | it("creates element with children multiple nodes", () => {
35 | const subject = render(
36 |
37 | <>{"childA"}>
38 | <>{"childB"}>
39 | ,
40 | );
41 | expect(subject.getByTestId("testId")).toMatchInlineSnapshot(`
42 |
45 | childA
46 | childB
47 |
48 | `);
49 | });
50 |
51 | it("creates element with children single nodes", () => {
52 | const subject = render(
53 |
54 | {"childA"}
55 | ,
56 | );
57 | expect(subject.getByTestId("testId")).toMatchInlineSnapshot(`
58 |
61 | childA
62 |
63 | `);
64 | });
65 |
66 | it("does not preserve ghost value on input element", () => {
67 | const domRef = React.createRef();
68 | const initialValue = "initial value";
69 | /*
70 | * Warning: You provided a `value` prop to a form field without an `onChange` handler.
71 | * This will render a read-only field. If the field should be mutable use `defaultValue`.
72 | * Otherwise, set either `onChange` or `readOnly`.
73 | */
74 | const { rerender } = render(
75 | ,
76 | );
77 | expect(domRef.current?.value).toEqual(initialValue);
78 | /*
79 | * Warning: A component is changing a controlled input to be uncontrolled.
80 | * This is likely caused by the value changing from a defined to undefined, which should not happen.
81 | * Decide between using a controlled or uncontrolled input element for the lifetime of the component.
82 | * More info: https://reactjs.org/link/controlled-components
83 | */
84 | rerender( );
85 | expect(domRef.current?.value).toHaveLength(0);
86 | });
87 | });
88 |
--------------------------------------------------------------------------------
/src/components/__tests__/Frame.test.tsx:
--------------------------------------------------------------------------------
1 | import { act, render } from "@testing-library/react";
2 | import * as React from "react";
3 |
4 | import { addDomStyles } from "../../__mocks__";
5 | import { Frame } from "../Frame";
6 |
7 | describe("Frame", () => {
8 | it("creates frame element with styles and children", async () => {
9 | // add document styles to be cloned
10 | const domStyle = addDomStyles();
11 |
12 | const subject = render(
13 |
14 | {"child text"}
15 | ,
16 | );
17 | const iframe = subject.getByTestId("testId") as HTMLIFrameElement;
18 |
19 | expect(iframe).toMatchInlineSnapshot(`
20 |
25 | `);
26 |
27 | expect(iframe.contentDocument?.firstElementChild).toMatchInlineSnapshot(`
28 |
29 |
30 |
31 |
35 |
40 |
54 |
55 | child text
56 |
57 |
58 |
59 | `);
60 |
61 | await act(async () => {
62 | domStyle.remove();
63 | });
64 |
65 | expect(iframe.contentDocument?.firstElementChild).toMatchInlineSnapshot(`
66 |
67 |
68 |
69 |
73 |
78 |
81 |
82 | child text
83 |
84 |
85 |
86 | `);
87 | });
88 | });
89 |
--------------------------------------------------------------------------------
/src/components/__tests__/Mirror.test.tsx:
--------------------------------------------------------------------------------
1 | import { act, render } from "@testing-library/react";
2 | import * as React from "react";
3 | import * as ReactDOM from "react-dom";
4 |
5 | import { addDomNode } from "../../__mocks__";
6 | import * as useDimensions from "../../hooks/useDimensions";
7 | import { Mirror } from "../Mirror";
8 |
9 | describe("Mirror", () => {
10 | const mathRandomSpy = jest.spyOn(Math, "random");
11 | const useDimensionsSpy = jest.spyOn(useDimensions, "useDimensions");
12 |
13 | beforeEach(() => {
14 | mathRandomSpy.mockReturnValue(0.123456789);
15 | useDimensionsSpy.mockReturnValue(new DOMRect());
16 | });
17 |
18 | afterEach(() => {
19 | jest.resetAllMocks();
20 | });
21 |
22 | it("frames an empty reflection", () => {
23 | const subject = render(
24 | ,
30 | );
31 | expect(subject.baseElement).toMatchInlineSnapshot(`
32 |
33 |
34 |
42 |
43 |
44 | `);
45 | });
46 |
47 | it("frames an empty reflection when find node errors", () => {
48 | const expectedError = Error("Expected test error");
49 | const findDOMNodeSpy = jest
50 | .spyOn(ReactDOM, "findDOMNode")
51 | .mockImplementation(() => {
52 | throw expectedError;
53 | });
54 | const consoleWarnSpy = jest
55 | .spyOn(console, "warn")
56 | .mockImplementation(() => undefined);
57 |
58 | const subject = render( );
59 |
60 | expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
61 | expect(consoleWarnSpy).toHaveBeenLastCalledWith(expectedError);
62 | expect(subject.baseElement).toMatchInlineSnapshot(`
63 |
64 |
65 |
72 |
73 |
74 | `);
75 |
76 | findDOMNodeSpy.mockRestore();
77 | });
78 |
79 | it("renders reflection with styles", async () => {
80 | /** add nodes that will be mirrored */
81 | const domNode = addDomNode();
82 | /** test reflection */
83 | const frameId = "mirrorFrame";
84 | const renderProps = {
85 | frameProps: { className: frameId, "data-test-id": frameId },
86 | reflect: domNode,
87 | };
88 |
89 | const subject = render( );
90 | expect(subject.baseElement).toMatchInlineSnapshot(`
91 |
92 |
93 |
97 |
100 |
101 | text content
102 |
103 |
104 |
113 |
114 |
115 | `);
116 |
117 | const iframe = subject.getByTestId(frameId) as HTMLIFrameElement;
118 | expect(iframe.contentDocument?.firstElementChild).toMatchInlineSnapshot(`
119 |
120 |
121 |
122 |
126 |
131 |
134 |
139 |
145 |
151 |
152 | text content
153 |
154 |
155 |
156 | `);
157 | /** clean up */
158 | await act(async () => {
159 | domNode.remove();
160 | });
161 | });
162 | });
163 |
--------------------------------------------------------------------------------
/src/components/__tests__/Reflection.test.tsx:
--------------------------------------------------------------------------------
1 | import { act, render } from "@testing-library/react";
2 | import * as React from "react";
3 |
4 | import { addDomNode } from "../../__mocks__";
5 | import { getAttributes } from "../../utils/dom";
6 | import { mergeStyleProps, Reflection } from "../Reflection";
7 |
8 | describe("Reflection", () => {
9 | it("reflects nothing", () => {
10 | const subject = render( );
11 | expect(subject.baseElement).toMatchInlineSnapshot(`
12 |
13 |
14 |
15 | `);
16 | });
17 |
18 | it("reflect text node", () => {
19 | const domNode = document.createTextNode("text content");
20 | const subject = render( );
21 | expect(subject.baseElement).toMatchInlineSnapshot(`
22 |
23 |
24 | text content
25 |
26 |
27 | `);
28 | });
29 |
30 | it("reflect html element", async () => {
31 | const domNode = addDomNode();
32 | const subject = render( );
33 | expect(subject.baseElement).toMatchInlineSnapshot(`
34 |
35 |
36 |
40 |
43 |
44 | text content
45 |
46 |
47 |
51 |
56 |
61 |
62 | text content
63 |
64 |
65 |
66 | `);
67 | await act(async () => void domNode.remove());
68 | });
69 |
70 | it("perseves style prop over element inline style", async () => {
71 | const domNode = addDomNode();
72 | domNode.dataset.testId = "test-id";
73 | const style = {
74 | color: "red",
75 | fontSize: "18px",
76 | borderWidth: "2px",
77 | };
78 | domNode.style.color = "blue";
79 | domNode.style.backgroundColor = "blue";
80 | domNode.style.border = "solid 1px green";
81 |
82 | const baseElement = document.createElement("div");
83 | const subject = render( , {
84 | baseElement,
85 | });
86 | const reflection = subject.getByTestId(
87 | domNode.dataset.testId,
88 | ) as HTMLDivElement;
89 |
90 | expect(reflection?.style.color).toEqual("red");
91 | expect(reflection?.style.backgroundColor).toEqual("blue");
92 | expect(reflection?.style.borderColor).toEqual("green");
93 | expect(reflection?.style.borderWidth).toEqual("2px");
94 | expect(reflection?.style.borderStyle).toEqual("solid");
95 |
96 | await act(async () => void domNode.remove());
97 | });
98 |
99 | it("perseves all classes from class name and list", async () => {
100 | const domNode = addDomNode();
101 | domNode.classList.add("realClsA");
102 | domNode.classList.add("realClsB");
103 | domNode.dataset.testId = "test-id";
104 | const baseElement = document.createElement("div");
105 |
106 | const subject = render(
107 | ,
108 | { baseElement },
109 | );
110 | const reflection = subject.getByTestId(
111 | domNode.dataset.testId,
112 | ) as HTMLDivElement;
113 |
114 | expect(Array.from(reflection.classList).sort()).toEqual([
115 | "clsReflect",
116 | "realClsA",
117 | "realClsB",
118 | "reflectionCls",
119 | ]);
120 |
121 | await act(async () => void domNode.remove());
122 | });
123 |
124 | it("tracks attribute changes in reflected node", async () => {
125 | const domNode = addDomNode();
126 | domNode.dataset.testId = "test-id";
127 | const baseElement = document.createElement("div");
128 |
129 | const subject = render( , { baseElement });
130 | const reflection = subject.getByTestId(
131 | domNode.dataset.testId,
132 | ) as HTMLDivElement;
133 |
134 | const expectedAttributes = {
135 | class: "",
136 | "data-test-id": "test-id",
137 | readonly: "",
138 | };
139 | expect(getAttributes(reflection)).toEqual(expectedAttributes);
140 |
141 | await act(async () => {
142 | domNode.setAttribute("newAttr", "newValue");
143 | });
144 | expect(getAttributes(reflection)).toEqual({
145 | ...expectedAttributes,
146 | newattr: "newValue",
147 | });
148 |
149 | await act(async () => void domNode.remove());
150 | });
151 |
152 | describe("mergeStyleProps", () => {
153 | const CSS_STYLE_NONE = document.createElement("span").style;
154 | const CSS_STYLE_DECL = document.createElement("span").style;
155 | CSS_STYLE_DECL.borderColor = "red";
156 |
157 | it.each`
158 | cssProps | inlineStyle | styleDecl | expectedStyle
159 | ${{}} | ${""} | ${CSS_STYLE_NONE} | ${{}}
160 | ${{}} | ${""} | ${CSS_STYLE_DECL} | ${{ borderColor: "red" }}
161 | ${{}} | ${"borderColor: green"} | ${CSS_STYLE_DECL} | ${{ borderColor: "green" }}
162 | ${{ borderColor: "blue" }} | ${"borderColor: green"} | ${CSS_STYLE_DECL} | ${{ borderColor: "blue" }}
163 | ${{ borderWidth: "1px" }} | ${"border-style: solid"} | ${CSS_STYLE_DECL} | ${{ borderColor: "red", borderStyle: "solid", borderWidth: "1px" }}
164 | `(
165 | "creates css style with expected props",
166 | ({ cssProps, expectedStyle, inlineStyle, styleDecl }) => {
167 | expect(mergeStyleProps(cssProps, inlineStyle, styleDecl)).toEqual(
168 | expectedStyle,
169 | );
170 | },
171 | );
172 | });
173 | });
174 |
--------------------------------------------------------------------------------
/src/components/__tests__/Window.test.tsx:
--------------------------------------------------------------------------------
1 | import { render } from "@testing-library/react";
2 | import * as React from "react";
3 |
4 | import * as usePortal from "../../hooks/usePortal";
5 | import * as useWindow from "../../hooks/useWindow";
6 | import { Window } from "../Window";
7 |
8 | const useWindowSpy = jest.spyOn(useWindow, "useWindow");
9 | const usePortalSpy = jest.spyOn(usePortal, "usePortal");
10 |
11 | describe("Window", () => {
12 | const documentBodyMock = document.createElement("body");
13 | const windowMock = {
14 | document: {
15 | body: documentBodyMock,
16 | },
17 | } as unknown as Window;
18 | const portalMock = (
) as React.ReactPortal;
19 |
20 | beforeEach(() => {
21 | useWindowSpy.mockReturnValue(windowMock);
22 | usePortalSpy.mockReturnValue(portalMock);
23 | });
24 |
25 | afterEach(() => {
26 | jest.resetAllMocks();
27 | });
28 |
29 | it("handles window failing to open successfully", () => {
30 | useWindowSpy.mockReturnValue(null);
31 | const childRef = React.createRef();
32 | const children = {"children"}
;
33 | render({children} );
34 |
35 | expect(useWindowSpy).toHaveBeenCalledTimes(1);
36 | expect(useWindowSpy).toHaveBeenCalledWith({});
37 | expect(usePortalSpy).toHaveBeenCalledTimes(1);
38 | expect(usePortalSpy).toHaveBeenCalledWith({
39 | source: children,
40 | });
41 | });
42 |
43 | it("renders children in window via portal", () => {
44 | const childRef = React.createRef();
45 | const children = {"children"}
;
46 | const windowProps = { url: "https://example.com" };
47 | const subject = render({children} );
48 |
49 | expect(useWindowSpy).toHaveBeenCalledTimes(1);
50 | expect(useWindowSpy).toHaveBeenCalledWith(windowProps);
51 | expect(usePortalSpy).toHaveBeenCalledTimes(1);
52 | expect(usePortalSpy).toHaveBeenCalledWith({
53 | source: children,
54 | target: documentBodyMock,
55 | });
56 |
57 | expect(subject.baseElement).toMatchInlineSnapshot(`
58 |
59 |
64 |
65 | `);
66 | expect(childRef.current?.ownerDocument).toMatchInlineSnapshot(`undefined`);
67 | });
68 | });
69 |
--------------------------------------------------------------------------------
/src/hooks/__tests__/useDimensions.test.ts:
--------------------------------------------------------------------------------
1 | import { renderHook } from "@testing-library/react";
2 | import { act } from "react-dom/test-utils";
3 |
4 | import { addDomNode } from "../../__mocks__";
5 | import { useDimensions } from "../useDimensions";
6 | import * as useObserver from "../useObserver";
7 |
8 | const observerMock = {} as MutationObserver & ResizeObserver;
9 | const useObserverSpy = jest.spyOn(useObserver, "useObserver");
10 |
11 | describe("useDimension", () => {
12 | afterEach(() => {
13 | jest.resetAllMocks();
14 | });
15 |
16 | it("returns empty dimension for nothing", () => {
17 | const { result } = renderHook(() => useDimensions());
18 | expect(result.current).toMatchInlineSnapshot(`
19 | DOMRect {
20 | "bottom": 0,
21 | "height": 0,
22 | "left": 0,
23 | "right": 0,
24 | "top": 0,
25 | "width": 0,
26 | "x": 0,
27 | "y": 0,
28 | }
29 | `);
30 | });
31 |
32 | it("returns dimension for rendered text node", async () => {
33 | document.body.style.fontSize = "18px";
34 | const textNode = document.createTextNode("text content");
35 | document.body.appendChild(textNode);
36 |
37 | const rangeMock = new Range();
38 | const rectMockA = new DOMRect(0, 0, 144, 48);
39 | const getBoundingClientRectSpy = jest
40 | .spyOn(rangeMock, "getBoundingClientRect")
41 | .mockReturnValueOnce(rectMockA);
42 | jest.spyOn(document, "createRange").mockReturnValue(rangeMock);
43 |
44 | const { result } = renderHook(() => useDimensions(textNode));
45 | expect(result.current).toEqual(rectMockA);
46 |
47 | expect(useObserverSpy).toHaveBeenLastCalledWith({
48 | ObserverClass: MutationObserver,
49 | initOptions: {
50 | characterData: true,
51 | },
52 | onUpdate: expect.any(Function),
53 | target: textNode,
54 | });
55 |
56 | const rectMockB = new DOMRect(0, 0, 132, 36);
57 | getBoundingClientRectSpy.mockReturnValueOnce(rectMockB);
58 | await act(async () => {
59 | useObserverSpy.mock.calls[0][0].onUpdate([], observerMock);
60 | });
61 | expect(result.current).toEqual(rectMockB);
62 | });
63 |
64 | it("returns dimension for rendered element", async () => {
65 | const domNode = addDomNode();
66 | const rectMockA = new DOMRect(0, 0, 156, 60);
67 | const getBoundingClientRectSpy = jest
68 | .spyOn(domNode, "getBoundingClientRect")
69 | .mockReturnValueOnce(rectMockA);
70 |
71 | const { result } = renderHook(() => useDimensions(domNode));
72 | expect(result.current).toEqual(rectMockA);
73 |
74 | expect(useObserverSpy).toHaveBeenLastCalledWith({
75 | ObserverClass: ResizeObserver,
76 | initOptions: {
77 | characterData: true,
78 | },
79 | onUpdate: expect.any(Function),
80 | target: domNode,
81 | });
82 |
83 | const rectMockB = new DOMRect(12, 12, 48, 144);
84 | getBoundingClientRectSpy.mockReturnValueOnce(rectMockB);
85 | await act(async () => {
86 | useObserverSpy.mock.calls[0][0].onUpdate([], observerMock);
87 | });
88 | expect(result.current).toEqual(rectMockB);
89 |
90 | await act(async () => void domNode.remove());
91 | });
92 | });
93 |
--------------------------------------------------------------------------------
/src/hooks/__tests__/useMirror.test.ts:
--------------------------------------------------------------------------------
1 | import { act, renderHook } from "@testing-library/react";
2 |
3 | import { useMirror } from "../useMirror";
4 |
5 | describe("useMirror", () => {
6 | it("renders mirror with props and target", async () => {
7 | const props = {
8 | frameProps: {},
9 | mirrorProp: "",
10 | };
11 | const { result } = renderHook(() => useMirror(props));
12 | expect(result.current[1]).toMatchInlineSnapshot(`
13 |
17 | `);
18 |
19 | const targetNode = document.createElement("div");
20 | await act(async () => result.current[0](targetNode));
21 |
22 | expect(result.current[1]).toMatchInlineSnapshot(`
23 | }
27 | />
28 | `);
29 | });
30 | });
31 |
--------------------------------------------------------------------------------
/src/hooks/__tests__/useObserver.test.ts:
--------------------------------------------------------------------------------
1 | import { renderHook } from "@testing-library/react";
2 |
3 | import { useObserver, UseObserverProps } from "../useObserver";
4 |
5 | const observerMock = {
6 | observe: jest.fn(),
7 | disconnect: jest.fn(),
8 | unobserve: jest.fn(),
9 | };
10 | const newObserverMock = (_: CallableFunction) => observerMock;
11 | const ObserverMock = jest.fn(newObserverMock);
12 |
13 | describe("useObserver", () => {
14 | const renderUseObserver = (
15 | initialProps: UseObserverProps,
16 | ) => {
17 | const useObserverHook = (
18 | props?: Partial>,
19 | ) => {
20 | return useObserver({ ...initialProps, ...props });
21 | };
22 | return renderHook(useObserverHook);
23 | };
24 |
25 | const getTargetMock = () => {
26 | const target = document.createElement("div");
27 | const spyOnProps = ["addEventListener", "removeEventListener"] as const;
28 | return {
29 | target,
30 | spies: Object.fromEntries(
31 | spyOnProps.map((prop) => [prop, jest.spyOn(target, prop)]),
32 | ),
33 | };
34 | };
35 |
36 | beforeEach(() => {
37 | ObserverMock.mockImplementation(newObserverMock);
38 | });
39 |
40 | afterEach(() => {
41 | jest.resetAllMocks();
42 | });
43 |
44 | it("successfully does nothing if target is null", async () => {
45 | const onUpdate = jest.fn();
46 | const hook = renderUseObserver({
47 | ObserverClass: ObserverMock,
48 | onEvents: new Set(["mousemove"]),
49 | onUpdate,
50 | });
51 | expect(ObserverMock).toHaveBeenCalledTimes(1);
52 | expect(ObserverMock).toHaveBeenLastCalledWith(onUpdate);
53 |
54 | hook.rerender(); // no prop changes, do not reinstantiate observer
55 | expect(ObserverMock).toHaveBeenCalledTimes(1);
56 |
57 | const onUpdateNew = jest.fn();
58 | hook.rerender({ onUpdate: onUpdateNew });
59 | expect(ObserverMock).toHaveBeenCalledTimes(2);
60 | expect(ObserverMock).toHaveBeenLastCalledWith(onUpdateNew);
61 |
62 | expect(observerMock.observe).not.toHaveBeenCalled();
63 |
64 | hook.unmount();
65 | expect(observerMock.disconnect).not.toHaveBeenCalled();
66 | });
67 |
68 | it("observes target using provided class", async () => {
69 | const { target, spies } = getTargetMock();
70 | const onUpdate = jest.fn();
71 | const onEvents = ["click", "keyup"] as const;
72 | const initOptions = {
73 | characterData: true,
74 | };
75 |
76 | const hook = renderUseObserver({
77 | ObserverClass: ObserverMock,
78 | initOptions,
79 | onUpdate,
80 | target,
81 | });
82 |
83 | expect(ObserverMock).toHaveBeenCalledTimes(1);
84 | expect(ObserverMock).toHaveBeenLastCalledWith(onUpdate);
85 | expect(observerMock.observe).toHaveBeenCalledTimes(1);
86 | expect(observerMock.observe).toHaveBeenLastCalledWith(target, initOptions);
87 | expect(observerMock.disconnect).not.toHaveBeenCalled();
88 | expect(spies.addEventListener).not.toHaveBeenCalled();
89 |
90 | hook.rerender({ onEvents: new Set(onEvents) });
91 | expect(spies.addEventListener).toHaveBeenCalledTimes(onEvents.length);
92 | onEvents.forEach((event) => {
93 | expect(spies.addEventListener).toHaveBeenCalledWith(
94 | event,
95 | onUpdate,
96 | false,
97 | );
98 | });
99 |
100 | hook.unmount();
101 | expect(observerMock.disconnect).toHaveBeenCalledTimes(1);
102 | expect(spies.removeEventListener).toHaveBeenCalledTimes(onEvents.length);
103 | onEvents.forEach((event) => {
104 | expect(spies.removeEventListener).toHaveBeenCalledWith(
105 | event,
106 | onUpdate,
107 | false,
108 | );
109 | });
110 | });
111 | });
112 |
--------------------------------------------------------------------------------
/src/hooks/__tests__/useRef.test.ts:
--------------------------------------------------------------------------------
1 | import { renderHook, RenderHookResult } from "@testing-library/react";
2 | import * as React from "react";
3 | import { act } from "react-dom/test-utils";
4 |
5 | import { useCallbackRef, useRefs, UseRefsResult } from "../useRef";
6 |
7 | const verifyCallbackHookBehaviour = async (
8 | hook: RenderHookResult, never>,
9 | ) => {
10 | expect(hook.result.current[0]).toBeUndefined();
11 |
12 | const expectedValue = "value";
13 | await act(async () => {
14 | hook.result.current[1](expectedValue);
15 | });
16 | expect(hook.result.current[0]).toBe(expectedValue);
17 |
18 | await act(async () => {
19 | hook.result.current[1](null);
20 | });
21 | expect(hook.result.current[0]).toBeUndefined();
22 | };
23 |
24 | describe("useCallbackRef", () => {
25 | it("sets value to undefined or else", async () => {
26 | const hook = renderHook(useCallbackRef);
27 | await verifyCallbackHookBehaviour(hook);
28 | });
29 | });
30 |
31 | describe("useRefs", () => {
32 | it("returns callback ref when no refs passed", async () => {
33 | const hook = renderHook(useRefs);
34 | await verifyCallbackHookBehaviour(hook);
35 | });
36 |
37 | it("sets all provided refs when callback ref is used", async () => {
38 | const objectRef = React.createRef();
39 | const callbackRef = jest.fn((_: string) => undefined);
40 |
41 | expect(objectRef.current).toBeNull();
42 | expect(callbackRef).not.toHaveBeenCalled();
43 |
44 | const hook = renderHook(() =>
45 | useRefs(callbackRef, null, undefined, objectRef),
46 | );
47 |
48 | const final = "expectedValue";
49 | await act(async () => void hook.result.current[1](final));
50 |
51 | expect(objectRef.current).toEqual(final);
52 | expect(callbackRef).toHaveBeenCalledTimes(1);
53 | expect(callbackRef).toHaveBeenLastCalledWith(final);
54 | });
55 | });
56 |
--------------------------------------------------------------------------------
/src/hooks/__tests__/useRenderTrigger.test.ts:
--------------------------------------------------------------------------------
1 | import { act, renderHook } from "@testing-library/react";
2 |
3 | import { useRenderTrigger } from "../useRenderTrigger";
4 |
5 | describe("useRenderTrigger", () => {
6 | it("count number of triggered renders", () => {
7 | const hook = renderHook(useRenderTrigger);
8 | expect(hook.result.current.count).toEqual(1);
9 | act(hook.result.current.rerender);
10 | expect(hook.result.current.count).toEqual(2);
11 | act(hook.result.current.rerender);
12 | act(hook.result.current.rerender);
13 | expect(hook.result.current.count).toEqual(4);
14 | });
15 | });
16 |
--------------------------------------------------------------------------------
/src/hooks/__tests__/useWindow.test.ts:
--------------------------------------------------------------------------------
1 | import { renderHook } from "@testing-library/react";
2 |
3 | import { useWindow } from "../useWindow";
4 |
5 | describe("useWindow", () => {
6 | const windowOpen = window.open;
7 | const windowOpenMock = jest.fn();
8 | const windowMock = {
9 | document: {},
10 | addEventListener: jest.fn(),
11 | } as unknown as Window;
12 |
13 | beforeEach(() => {
14 | window.open = windowOpenMock;
15 | windowOpenMock.mockImplementation(() => ({ ...windowMock }));
16 | });
17 |
18 | afterEach(() => {
19 | jest.resetAllMocks();
20 | window.open = windowOpen;
21 | });
22 |
23 | it("creates window with correct props", () => {
24 | const url = "https://example.com";
25 | const target = "Window Title";
26 | const hook = renderHook(() =>
27 | useWindow({
28 | features: {
29 | popup: "yes",
30 | height: 400,
31 | left: 0,
32 | noopener: true,
33 | noreferrer: true,
34 | width: 400,
35 | top: 12,
36 | },
37 | target,
38 | url,
39 | }),
40 | );
41 |
42 | expect(hook.result.current).toEqual(expect.objectContaining(windowMock));
43 | expect(hook.result.current?.document.title).toBeUndefined();
44 |
45 | hook.result.current!.onbeforeunload!(new Event("beforeunload"));
46 | expect(windowOpenMock).toHaveBeenCalledTimes(1);
47 | expect(windowOpenMock).toHaveBeenCalledWith(
48 | url,
49 | target,
50 | "height=400,left=0,noopener,noreferrer,popup,top=12,width=400",
51 | );
52 | });
53 |
54 | it("uses target as window title when url is not given", () => {
55 | const target = "Window Title";
56 | const hook = renderHook(() => useWindow({ target, url: "" }));
57 |
58 | expect(hook.result.current).toEqual(expect.objectContaining(windowMock));
59 | expect(hook.result.current?.document.title).toEqual(target);
60 | });
61 |
62 | it("calls onClose before window is unloaded", () => {
63 | const onClose = jest.fn();
64 | const hook = renderHook(() => useWindow({ onClose }));
65 | expect(onClose).not.toHaveBeenCalled();
66 | hook.result.current?.onbeforeunload?.(new Event("beforeunload"));
67 | expect(onClose).toHaveBeenCalledTimes(1);
68 | });
69 | });
70 |
--------------------------------------------------------------------------------
/src/hooks/useDimensions.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { getBounds, isElement } from "../utils/dom";
4 | import { useObserver } from "./useObserver";
5 |
6 | export function useDimensions(target?: Node) {
7 | // get the dimensions of the current target
8 | const getDimensions = React.useCallback(() => {
9 | return getBounds(target);
10 | }, [target]);
11 | // track dimensions and trigger rerender on set
12 | const [dimensions, setDimensions] = React.useState(getDimensions);
13 | // update dimensions when triggered
14 | const onUpdate = React.useCallback(() => {
15 | setDimensions(getDimensions());
16 | }, [getDimensions]);
17 | // track resizes and trigger update
18 | useObserver({
19 | ObserverClass: isElement(target) ? ResizeObserver : MutationObserver,
20 | onUpdate,
21 | target,
22 | initOptions: {
23 | characterData: true,
24 | },
25 | });
26 |
27 | return dimensions;
28 | }
29 |
--------------------------------------------------------------------------------
/src/hooks/useEventHandlers.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | export type EventHandlerTarget = Pick<
4 | GlobalEventHandlers,
5 | "addEventListener" | "removeEventListener"
6 | >;
7 |
8 | export function useEventHandlers(
9 | target?: EventHandlerTarget,
10 | onEvents?: (readonly [
11 | keyof GlobalEventHandlersEventMap,
12 | Parameters[1],
13 | ])[],
14 | ) {
15 | React.useEffect(
16 | function onEventHandlers() {
17 | if (!target || !onEvents) return;
18 | onEvents.forEach(([eventType, listener]) => {
19 | target.addEventListener(eventType, listener, false);
20 | });
21 |
22 | return function destroy() {
23 | onEvents.forEach(([eventType, listener]) => {
24 | target.removeEventListener(eventType, listener, false);
25 | });
26 | };
27 | },
28 | [onEvents, target],
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/src/hooks/useMirror.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { Mirror, MirrorProps } from "../components/Mirror";
4 | import { useCallbackRef } from "./useRef";
5 |
6 | export function useMirror(props?: MirrorProps) {
7 | const [node, ref] = useCallbackRef();
8 | return [ref, ] as const;
9 | }
10 |
--------------------------------------------------------------------------------
/src/hooks/useObserver.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { useEventHandlers } from "./useEventHandlers";
4 |
5 | export interface UseObserverProps<
6 | T extends typeof MutationObserver | typeof ResizeObserver,
7 | > {
8 | initOptions?: Parameters["observe"]>[1];
9 | target?: Parameters["observe"]>[0];
10 | ObserverClass: T;
11 | /**
12 | * Also listen for other changes that are not observable.
13 | *
14 | * NOTE: Events will trigger `onUpdate` with no parameters unlike the mutation/resize triggered with records and reference to observer.
15 | */
16 | onEvents?: Set;
17 | onUpdate: ConstructorParameters[0];
18 | }
19 |
20 | export function useObserver<
21 | T extends typeof MutationObserver | typeof ResizeObserver,
22 | >({ initOptions, ObserverClass, onEvents, ...props }: UseObserverProps) {
23 | const target = props.target as Node & Element;
24 | const onUpdate = props.onUpdate as EventListener &
25 | MutationCallback &
26 | ResizeObserverCallback;
27 |
28 | const observer = React.useMemo(() => {
29 | return new ObserverClass(onUpdate);
30 | }, [ObserverClass, onUpdate]);
31 |
32 | React.useEffect(
33 | function observeRealElement() {
34 | if (!target || !observer) return;
35 | observer.observe(target, initOptions);
36 |
37 | return function destroy() {
38 | observer.disconnect();
39 | };
40 | },
41 | [initOptions, observer, onUpdate, target],
42 | );
43 |
44 | const handlers = React.useMemo(() => {
45 | return Array.from(onEvents ?? []).map(
46 | (eventType) => [eventType, onUpdate] as const,
47 | );
48 | }, [onEvents, onUpdate]);
49 |
50 | useEventHandlers(target, handlers);
51 | }
52 |
--------------------------------------------------------------------------------
/src/hooks/usePortal.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as ReactDOM from "react-dom";
3 |
4 | export interface UsePortalProps {
5 | key?: string;
6 | source: React.ReactNode;
7 | target?: Element;
8 | }
9 |
10 | export function usePortal({ key, source, target }: UsePortalProps) {
11 | return React.useMemo(
12 | function createPortal() {
13 | if (!target) return null;
14 | return ReactDOM.createPortal(source, target, key);
15 | },
16 | [key, source, target],
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/src/hooks/useRef.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | export function useCallbackRef() {
4 | const [current, setRef] = React.useState(null);
5 | return [current ?? undefined, setRef] as const;
6 | }
7 |
8 | export type UseRefsResult = readonly [T | undefined, (v: T | null) => void];
9 |
10 | export function useRefs(ref: React.Ref | undefined): UseRefsResult;
11 | export function useRefs(
12 | ...refs: (React.Ref | undefined)[]
13 | ): UseRefsResult;
14 | export function useRefs(
15 | ...refs: (React.Ref | undefined)[]
16 | ): UseRefsResult {
17 | const [ref, setRef] = useCallbackRef();
18 |
19 | const setRefs = React.useCallback(
20 | function setRefs(current: T | null) {
21 | setRef((previous) => {
22 | if (previous !== current) {
23 | refs.forEach((ref) => {
24 | if (!ref) return;
25 | else if (typeof ref === "function") {
26 | ref(current);
27 | } else if (ref.current !== current) {
28 | Object.assign(ref, { current });
29 | }
30 | });
31 | }
32 | return current;
33 | });
34 | },
35 | [refs, setRef],
36 | );
37 |
38 | return [ref, setRefs] as const;
39 | }
40 |
--------------------------------------------------------------------------------
/src/hooks/useRenderTrigger.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | export function useRenderTrigger() {
4 | const [count, setCount] = React.useState(1);
5 | const rerender = React.useCallback(
6 | () => setCount((count) => count + 1),
7 | [setCount],
8 | );
9 |
10 | return {
11 | count,
12 | rerender,
13 | };
14 | }
15 |
--------------------------------------------------------------------------------
/src/hooks/useWindow.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | export interface UseWindowProps {
4 | /** Window features https://developer.mozilla.org/en-US/docs/Web/API/Window/open#windowfeatures */
5 | features?: {
6 | popup?: true | 1 | "yes";
7 | height?: number;
8 | innerHeight?: number;
9 | left?: number;
10 | screenX?: number;
11 | noopener?: true;
12 | noreferrer?: true;
13 | top?: number;
14 | screenY?: number;
15 | width?: number;
16 | innerWidth?: number;
17 | };
18 | onClose?: () => void;
19 | /** Also used as the window title when URL is blank */
20 | target?: string;
21 | url?: string;
22 | }
23 |
24 | export function useWindow({
25 | url = "",
26 | features = {},
27 | target,
28 | onClose = () => undefined,
29 | }: UseWindowProps) {
30 | return React.useMemo(() => {
31 | const featureList = Object.entries(features)
32 | .map(
33 | ([prop, value]) =>
34 | prop + (typeof value === "number" ? `=${value}` : ""),
35 | )
36 | .sort()
37 | .join(",");
38 | const wndo = window.open(url, target, featureList);
39 | if (wndo) {
40 | wndo.onbeforeunload = onClose;
41 | if (target && !url) wndo.document.title = target;
42 | }
43 | return wndo;
44 | }, [features, onClose, target, url]);
45 | }
46 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export { Frame, FrameProps, FrameStyles } from "./components/Frame";
2 | export { Mirror, MirrorProps } from "./components/Mirror";
3 | export { Reflection, ReflectionProps } from "./components/Reflection";
4 | export { Window, WindowProps } from "./components/Window";
5 | export { useDimensions } from "./hooks/useDimensions";
6 | export { useMirror } from "./hooks/useMirror";
7 | export { useRefs } from "./hooks/useRef";
8 |
--------------------------------------------------------------------------------
/src/utils/dom.ts:
--------------------------------------------------------------------------------
1 | import { flatten } from ".";
2 |
3 | export function getChildren(element?: Element | Text) {
4 | const children: (Element | Text)[] = [];
5 | if (isElement(element)) {
6 | element.childNodes.forEach((childNode) => {
7 | if (isElement(childNode) || isText(childNode)) {
8 | children.push(childNode);
9 | }
10 | });
11 | }
12 | return children;
13 | }
14 |
15 | export function getAttributes(element?: Element | Text) {
16 | const attributes: Record = {};
17 | if (isElement(element)) {
18 | Array.from(element.attributes).forEach((attr) => {
19 | attributes[attr.name] = attr.value;
20 | });
21 | }
22 | const fieldValue = getValue(element);
23 | if (fieldValue) attributes.value = fieldValue;
24 | return attributes;
25 | }
26 |
27 | function getValue(node?: Node) {
28 | return (node as HTMLInputElement)?.value;
29 | }
30 |
31 | export function getBounds(node?: Node) {
32 | if (isElement(node)) {
33 | return node.getBoundingClientRect();
34 | }
35 | if (isText(node)) {
36 | const range = document.createRange();
37 | range.selectNodeContents(node);
38 | return range.getBoundingClientRect();
39 | }
40 | return new DOMRect();
41 | }
42 |
43 | export function isElement(node?: Node): node is Element {
44 | return node ? !isText(node) : false;
45 | }
46 |
47 | export function isText(node?: Node): node is Text {
48 | return node?.nodeType === Node.TEXT_NODE;
49 | }
50 |
51 | /**
52 | * Get all style rules from window document sorted by selector text
53 | */
54 | export function getAllStyleRules() {
55 | return flatten(
56 | ...Array.from(document.styleSheets).map((sheet) => {
57 | return Array.from(sheet.cssRules).map((rule) => {
58 | return withCustomerUserActionPseudoClassSelector(rule.cssText);
59 | });
60 | }),
61 | ).sort();
62 | }
63 |
64 | const USER_ACTION_PSEUDO_CLASS_LIST = [
65 | "active",
66 | "hover",
67 | "focus",
68 | // FIX: https://github.com/jsdom/jsdom/issues/3426
69 | // "focus-visible",
70 | // FIX: https://github.com/jsdom/jsdom/issues/3055
71 | // "focus-within",
72 | ] as const;
73 | type UserActionPseudoClass = (typeof USER_ACTION_PSEUDO_CLASS_LIST)[number];
74 |
75 | function withCustomerUserActionPseudoClassSelector(cssSelector: string) {
76 | const userActionPseudoClassesRegex = new RegExp(
77 | USER_ACTION_PSEUDO_CLASS_LIST.map(userActionAsPseudoClassSelector).join(
78 | "|",
79 | ),
80 | );
81 |
82 | return cssSelector.replace(
83 | userActionPseudoClassesRegex,
84 | (cls) => `.${asCustomPseudoClass(cls.substring(1))}`,
85 | );
86 | }
87 |
88 | function userActionAsPseudoClassSelector(cls: UserActionPseudoClass) {
89 | return `:${cls}`;
90 | }
91 |
92 | const CUSTOM_CLASS_PREFIX = "_refl_" as const;
93 | function asCustomPseudoClass(cls: unknown) {
94 | return `${CUSTOM_CLASS_PREFIX}${cls}`;
95 | }
96 |
97 | export function getUserActionCustomPseudoClassList(el?: Element | Text) {
98 | return getUserActionPseudoClassList(el).map(asCustomPseudoClass);
99 | }
100 |
101 | function getUserActionPseudoClassList(el?: Element | Text) {
102 | if (!isElement(el)) return [];
103 | return USER_ACTION_PSEUDO_CLASS_LIST.filter((cls) =>
104 | el.matches(`*${userActionAsPseudoClassSelector(cls)}`),
105 | );
106 | }
107 |
108 | type ScrollableElement = Pick;
109 |
110 | export function syncScroll(
111 | source?: ScrollableElement,
112 | target?: ScrollableElement,
113 | ) {
114 | if (!source || !target) return;
115 | target.scrollTop = source.scrollTop;
116 | target.scrollLeft = source.scrollLeft;
117 | }
118 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | export function randomString(length: number) {
2 | let str = "";
3 | while (str.length < length) {
4 | str += Math.random()
5 | .toString(36)
6 | .substr(2, length - str.length);
7 | }
8 | return str;
9 | }
10 |
11 | export function spinalToCamelCase(spinalCase: string): string {
12 | return spinalCase.replace(/-[a-z]/g, (m) => m.substring(1).toUpperCase());
13 | }
14 |
15 | export function flatten(...items: (T | T[])[]) {
16 | return ([] as T[]).concat(...items);
17 | }
18 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowSyntheticDefaultImports": true,
4 | "baseUrl": ".",
5 | "jsx": "react",
6 | "moduleResolution": "node",
7 | "outDir": "./lib",
8 | "strict": true,
9 | "target": "es6",
10 | },
11 | "exclude": ["lib", "node_modules"]
12 | }
13 |
--------------------------------------------------------------------------------
/tsconfig.prod.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "include": [
4 | "src",
5 | "typings"
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/webpack.config.ts:
--------------------------------------------------------------------------------
1 | import { execSync } from "child_process";
2 | import * as path from "path";
3 | import { Configuration } from "webpack";
4 | import { WebpackCompilerPlugin } from "webpack-compiler-plugin";
5 | import * as nodeExternals from "webpack-node-externals";
6 |
7 | const configuration: Configuration = {
8 | devtool: "source-map",
9 | entry: "./src",
10 | mode: "production",
11 | module: {
12 | rules: [
13 | {
14 | exclude: /(node_modules|bower_components)/,
15 | test: /\.tsx?$/,
16 | use: [
17 | {
18 | loader: "babel-loader",
19 | options: {
20 | presets: ["@babel/preset-typescript"],
21 | },
22 | },
23 | {
24 | loader: "ts-loader",
25 | options: {
26 | configFile: "tsconfig.prod.json",
27 | },
28 | },
29 | ],
30 | },
31 | ],
32 | },
33 | output: {
34 | filename: "main.js",
35 | libraryTarget: "commonjs",
36 | path: path.resolve(__dirname, "lib"),
37 | },
38 | plugins: [
39 | new WebpackCompilerPlugin({
40 | name: "script-build-types",
41 | listeners: {
42 | compileStart: (): void => {
43 | try {
44 | execSync("pnpm build-types");
45 | } catch (e) {
46 | // eslint-disable-next-line no-console
47 | console.error(e);
48 | }
49 | },
50 | },
51 | }),
52 | ],
53 | resolve: {
54 | extensions: [".js", ".ts", ".jsx", ".tsx"],
55 | modules: [path.resolve("./src"), path.resolve("./node_modules")],
56 | },
57 | watchOptions: {
58 | ignored: /node_modules|built|lib/,
59 | },
60 | externals: [nodeExternals()] as Configuration["externals"],
61 | };
62 |
63 | export default configuration;
64 |
--------------------------------------------------------------------------------