├── .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 | [![NPM badge](https://img.shields.io/npm/v/react-mirror)](https://www.npmjs.com/package/react-mirror) 6 | [![Dependabot badge](https://badgen.net/github/dependabot/iamogbz/react-mirror/?icon=dependabot)](https://app.dependabot.com) 7 | [![Dependencies](https://img.shields.io/librariesio/github/iamogbz/react-mirror)](https://libraries.io/github/iamogbz/react-mirror) 8 | [![Build Status](https://github.com/iamogbz/react-mirror/workflows/Build/badge.svg)](https://github.com/iamogbz/react-mirror/actions) 9 | [![Coverage Status](https://coveralls.io/repos/github/iamogbz/react-mirror/badge.svg?branch=refs/heads/main)](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 | 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 | 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 ; 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 |