Repository: AlexanderRichey/styled-react-modal Branch: main Commit: 26164e3cfaba Files: 22 Total size: 33.8 KB Directory structure: gitextract_2y96oc27/ ├── .babelrc ├── .circleci/ │ └── config.yml ├── .codesandbox/ │ └── ci.json ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .hound.yml ├── .npmignore ├── .nvmrc ├── .prettierrc ├── LICENSE ├── README.md ├── build/ │ ├── mjs/ │ │ └── index.mjs │ └── umd/ │ └── index.js ├── package.json ├── rollup.config.js ├── src/ │ ├── Modal.jsx │ ├── ModalProvider.jsx │ ├── baseStyles.jsx │ ├── context.jsx │ └── index.jsx └── tests/ └── Modal.test.jsx ================================================ FILE CONTENTS ================================================ ================================================ FILE: .babelrc ================================================ { "presets": ["@babel/env", "@babel/react"], "plugins": [ ["@babel/plugin-transform-runtime"], ["babel-plugin-styled-components", { "displayName": true, "ssr": false }] ] } ================================================ FILE: .circleci/config.yml ================================================ version: 2.1 # Orbs are reusable packages of CircleCI configuration that you may share across projects, enabling you to create encapsulated, parameterized commands, jobs, and executors that can be used across multiple projects. # See: https://circleci.com/docs/2.0/orb-intro/ orbs: node: circleci/node@5.0.2 codecov: codecov/codecov@3.2.3 # Invoke jobs via workflows # See: https://circleci.com/docs/2.0/configuration-reference/#workflows workflows: test: jobs: - node/test: # This is the node version to use for the `cimg/node` tag # Relevant tags can be found on the CircleCI Developer Hub # https://circleci.com/developer/images/image/cimg/node version: "16.17" pkg-manager: yarn post-steps: - codecov/upload ================================================ FILE: .codesandbox/ci.json ================================================ { "sandboxes": ["m9jlky57y"] } ================================================ FILE: .eslintignore ================================================ build/ node_modules/ coverage/ ================================================ FILE: .eslintrc ================================================ { "extends": "prettier", "parser": "babel-eslint", "plugins": ["prettier", "react"] } ================================================ FILE: .gitignore ================================================ # dependencies /node_modules # misc .DS_Store npm-debug.log* yarn-debug.log* yarn-error.log* /coverage ================================================ FILE: .hound.yml ================================================ eslint: enabled: true config_file: .eslintrc ignore_file: .eslintignore ================================================ FILE: .npmignore ================================================ coverage/ ================================================ FILE: .nvmrc ================================================ v18.7.0 ================================================ FILE: .prettierrc ================================================ { "printWidth": 80, "tabWidth": 2, "useTabs": false, "semi": true, "singleQuote": false, "trailingComma": "none", "bracketSpacing": true, "jsxBracketSameLine": false, "fluid": false } ================================================ FILE: LICENSE ================================================ Copyright (c) 2022 Alexander Richey This is free and unencumbered software released into the public domain. Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means. In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. For more information, please refer to ================================================ FILE: README.md ================================================ # Styled React Modal [![style: styled-components](https://img.shields.io/badge/style-%F0%9F%92%85%20styled--components-orange.svg?colorB=daa357&colorA=db748e)](https://github.com/styled-components/styled-components) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) [![npm version](https://img.shields.io/npm/v/styled-react-modal.svg)](https://www.npmjs.com/package/styled-react-modal) [![npm downloads](https://img.shields.io/npm/dm/styled-react-modal.svg)](https://www.npmjs.com/package/styled-react-modal) [![CircleCI](https://dl.circleci.com/status-badge/img/gh/AlexanderRichey/styled-react-modal/tree/main.svg?style=shield)](https://dl.circleci.com/status-badge/redirect/gh/AlexanderRichey/styled-react-modal/tree/main) [![codecov](https://codecov.io/gh/AlexanderRichey/styled-react-modal/branch/main/graph/badge.svg)](https://codecov.io/gh/AlexanderRichey/styled-react-modal) > For support for **react <16.9**, please use **styled-react-modal@1.2.4**. > For support for **create-react-app <5.0.0**, please import from `styled-react-modal/build/umd`. Styled React Modal is built with styled-components. It uses the latest React 17.x features and exposes a familiar, easy to use API. It supports `beforeOpen()`, `afterOpen()`, and other lifecycle hooks so that animations can be handled easily. Unlike several other modal implementations in React, it does not pollute the DOM with excessive nodes. [**Demo on CodeSandbox**](https://codesandbox.io/s/m9jlky57y) ## Install ``` npm i -s styled-react-modal # or use yarn ``` ## Usage Add the `` component near the top of your application's tree. ```js import React from 'react' import { ModalProvider } from 'styled-react-modal' ... export default function App() { return ( ) } ``` Use the `` component. > For instructions on how the make your modal accessible according to the [WAI-ARIA spec](https://www.w3.org/TR/wai-aria-practices/#dialog_modal), see [this CodeSandbox](https://codesandbox.io/s/styled-react-modal-a11y-9uco3?file=/src/index.js). ```js import Modal from 'styled-react-modal' ... const StyledModal = Modal.styled` width: 20rem; height: 20rem; display: flex; align-items: center; justify-content: center; background-color: ${props => props.theme.colors.white}; ` function FancyModalButton() { const [isOpen, setIsOpen] = useState(false) function toggleModal(e) { setIsOpen(!isOpen) } return (
I am a modal!
) } ``` ## API #### Top-Level Exports - `` - `Modal` \(Default\) - `Modal.styled(styles)` - `` - ``
### `` Sets the root portal where ``s will be rendered. **Props** - [`backgroundComponent`] \(Component\): A styled component to be used as the default modal background. If not provided, library defaults will be used. *Example:* ```js import { ModalProvider } from 'styled-react-modal' const SpecialModalBackground = styled.div` display: flex; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: 30; opacity: ${props => props.opacity}; background-color: green; ` export default function App() { return ( ) } ``` ### `Modal.styled(styles)` Factory method that accepts a tagged template literal and returns a `` component with styles included. **Arguments** - `styles` \(Tagged Template Literal\): styled-components compatible css styles. *Example:* ```js const StyledModal = Modal.styled` width: 20rem; height: 20rem; display: flex; align-items: center; justify-content: center; background-color: ${props => props.theme.colors.white}; ` ``` ### `` Renders its children in a modal when open, nothing when not open. **Props** - `isOpen` \(Boolean\): A boolean that indicates whether the modal is to be open or closed. - [`onBackgroundClick`] \(Function\): A function that is called when the modal background is clicked. - [`onEscapeKeydown`] \(Function\): A function that is called when the escape key is pressed while the modal is open. - [`backgroundProps`] \(Object\): A props object that is spread over the `backgroundComponent` when included. - [`allowScroll`] \(Boolean\): When true, scrolling in the document body is not disabled when the modal is open. - [`beforeOpen`] \(Function\): A function that is called before the modal opens. If this function returns a promise, then the modal is opened after the promise is resolved. - [`afterOpen`] \(Function\): A function that is called after the modal opens. - [`beforeClose`] \(Function\): A function that is called before the modal closes. If this function returns a promise, then the modal is closed after the promise is resolved. - [`afterClose`] \(Function\): A function that is called after the modal closes. *Example:* ```js import Modal from 'styled-react-modal' function FancyModalButton() { const [isOpen, setIsOpen] = useState(false) function toggleModal(e) { setIsOpen(!isOpen) } return (
I am a modal!
) } ``` ### `` A convenience base component for making default background styles with ``. *Example:* ```js const SpecialModalBackground = styled(BaseModalBackground)` background-color: green; ` ``` ================================================ FILE: build/mjs/index.mjs ================================================ import e from"styled-components";import n,{useRef as r,useState as t,useEffect as o}from"react";import a from"react-dom";var c=e.div.withConfig({displayName:"baseStyles__BaseModalBackground"})(["display:flex;position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:30;background-color:rgba(0,0,0,0.5);align-items:center;justify-content:center;"]);function l(e,n){(null==n||n>e.length)&&(n=e.length);for(var r=0,t=new Array(n);r=0||(o[r]=e[r]);return o}(e,n);if(Object.getOwnPropertySymbols){var a=Object.getOwnPropertySymbols(e);for(t=0;t=0||Object.prototype.propertyIsEnumerable.call(e,r)&&(o[r]=e[r])}return o}h.propTypes={backgroundComponent:m.oneOfType([m.element,m.object])};var C=["WrapperComponent","children","onBackgroundClick","onEscapeKeydown","allowScroll","beforeOpen","afterOpen","beforeClose","afterClose","backgroundProps","isOpen"];function k(e){for(var n=arguments.length,r=new Array(n>1?n-1:0),t=1;te.length)&&(n=e.length);for(var t=0,r=new Array(n);t=0||(o[t]=e[t]);return o}(e,n);if(Object.getOwnPropertySymbols){var a=Object.getOwnPropertySymbols(e);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(e,t)&&(o[t]=e[t])}return o}g.propTypes={backgroundComponent:y.oneOfType([y.element,y.object])};var w=["WrapperComponent","children","onBackgroundClick","onEscapeKeydown","allowScroll","beforeOpen","afterOpen","beforeClose","afterClose","backgroundProps","isOpen"];function C(e){for(var n=arguments.length,t=new Array(n>1?n-1:0),r=1;r", "license": "Unlicense", "bugs": { "url": "https://github.com/AlexanderRichey/styled-react-modal/issues" }, "homepage": "https://github.com/AlexanderRichey/styled-react-modal#readme", "peerDependencies": { "react": ">=18 <19", "react-dom": ">=18 <19", "styled-components": "^6.1.1" }, "devDependencies": { "@babel/core": "^7.19.1", "@babel/plugin-transform-runtime": "^7.19.1", "@babel/preset-env": "^7.19.1", "@babel/preset-react": "^7.18.6", "@rollup/plugin-babel": "^5.3.1", "@rollup/plugin-node-resolve": "^14.1.0", "@rollup/plugin-replace": "^4.0.0", "@testing-library/dom": "^8.18.1", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", "babel-eslint": "^10.1.0", "babel-jest": "^29.0.3", "babel-plugin-styled-components": "^2.0.7", "babel-plugin-transform-object-rest-spread": "^6.26.0", "eslint": "^8.23.1", "eslint-config-prettier": "^8.5.0", "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-react": "^7.31.8", "jest": "^29.0.3", "jest-environment-jsdom": "^29.0.3", "prettier": "^2.7.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-is": "^18.2.0", "react-test-renderer": "^18.2.0", "rollup": "^2.79.0", "rollup-plugin-commonjs": "^10.1.0", "rollup-plugin-terser": "^7.0.2", "styled-components": "^6.1.1" }, "resolutions": { "eslint-utils": "^2.0.0", "mixin-deep": "^2.0.1", "set-value": "^3.0.2", "minimist": "^0.2.1", "acorn": "^6.4.1", "kind-of": "^6.0.3", "serialize-javascript": "^3.1.0", "ssri": "^8.0.1", "node-notifier": "^8.0.1", "terser": "5.14.2", "ajv": "6.12.6" }, "jest": { "coverageDirectory": "./coverage/", "collectCoverage": true, "testEnvironment": "jsdom" }, "dependencies": { "prop-types": "^15.8.1" }, "packageManager": "yarn@1.22.19+sha1.4ba7fc5c6e704fce2066ecbfb0b0d8976fe62447" } ================================================ FILE: rollup.config.js ================================================ import resolve from "@rollup/plugin-node-resolve"; import replace from "@rollup/plugin-replace"; import { terser } from "rollup-plugin-terser"; import babel from "@rollup/plugin-babel"; import commonjs from "rollup-plugin-commonjs"; export default { input: "src/index.jsx", output: [ { name: "styled-react-modal", file: "build/umd/index.js", format: "umd", exports: "named", globals: { react: "React", "react-dom": "ReactDOM", "styled-components": "styled" } }, { name: "styled-react-modal", exports: "named", file: "build/mjs/index.mjs", format: "es" } ], plugins: [ resolve({ extensions: [".jsx", ".js"], moduleDirectories: ["node_modules"] }), replace({ "process.env.NODE_ENV": JSON.stringify("production"), preventAssignment: true }), commonjs({ include: /node_modules/ }), babel({ babelHelpers: "runtime", extensions: [".jsx", ".js"], exclude: /(node_modules|build)/ }), terser() ], external: ["react", "react-dom", "styled-components"] }; ================================================ FILE: src/Modal.jsx ================================================ import React, { useState, useRef, useEffect } from "react"; import ReactDOM from "react-dom"; import styled from "styled-components"; import PropTypes from "prop-types"; import { Consumer } from "./context"; function callIfDefined(fun, ...args) { fun && fun(...args); } function Modal({ WrapperComponent, children, onBackgroundClick, onEscapeKeydown, allowScroll, beforeOpen, afterOpen, beforeClose, afterClose, backgroundProps = {}, isOpen: isOpenProp, ...rest }) { const node = useRef(null); const prevBodyOverflowStyle = useRef(null); const isTransitioning = useRef(false); const [isOpen, setIsOpen] = useState(false); // Handle opening and closing useEffect(() => { function handleIsOpenChange(value) { setIsOpen(value); value ? callIfDefined(afterOpen) : callIfDefined(afterClose); } function handleChange(callback) { if (callback) { const maybePromise = callback(); if (typeof maybePromise?.then === "function") { isTransitioning.current = true; maybePromise.then(() => { handleIsOpenChange(isOpenProp); isTransitioning.current = false; }); } else { handleIsOpenChange(isOpenProp); } } else { handleIsOpenChange(isOpenProp); } } if (isOpen !== isOpenProp && !isTransitioning.current) { if (isOpenProp) { handleChange(beforeOpen); } else { handleChange(beforeClose); } } }, [ isTransitioning, isOpen, isOpenProp, beforeOpen, beforeClose, afterClose, afterOpen ]); // Handle Escape keydown useEffect(() => { function handleKeydown(e) { if (e.key === "Escape") { onEscapeKeydown && onEscapeKeydown(e); } } if (isOpen) { document.addEventListener("keydown", handleKeydown); } return () => { document.removeEventListener("keydown", handleKeydown); }; }, [isOpen, onEscapeKeydown]); // Handle changing document.body styles based on isOpen state useEffect(() => { if (isOpen && !allowScroll) { prevBodyOverflowStyle.current = document.body.style.overflow; document.body.style.overflow = "hidden"; } return () => { if (!allowScroll) { document.body.style.overflow = prevBodyOverflowStyle.current || ""; } }; }, [isOpen, allowScroll]); function handleBackgroundClick(e) { if (node.current === e.target) { onBackgroundClick && onBackgroundClick(e); } } let content; if (WrapperComponent) { content = {children}; } else { content = children; } return ( {({ modalNode, BackgroundComponent }) => { if (modalNode && BackgroundComponent && isOpen) { return ReactDOM.createPortal( {content} , modalNode ); } else { return null; } }} ); } Modal.styled = function (...args) { const wrap = args ? styled.div(...args) : styled.div(); return function (props) { return ; }; }; Modal.propTypes = { WrapperComponent: PropTypes.oneOfType([PropTypes.element, PropTypes.object]), onBackgroundClick: PropTypes.func, onEscapeKeydown: PropTypes.func, allowScroll: PropTypes.bool, beforeOpen: PropTypes.func, afterOpen: PropTypes.func, beforeClose: PropTypes.func, afterClose: PropTypes.func, backgroundProps: PropTypes.object, isOpen: PropTypes.bool }; export default Modal; ================================================ FILE: src/ModalProvider.jsx ================================================ import React, { useState, useEffect, useRef } from "react"; import PropTypes from "prop-types"; import { BaseModalBackground } from "./baseStyles"; import { Provider } from "./context"; function ModalProvider({ backgroundComponent: propsBackgroundComponent, children }) { const modalNode = useRef(null); const [stateModalNode, setStateModalNode] = useState(null); const [BackgroundComponent, setBackgroundComponent] = useState( BaseModalBackground ); useEffect(() => { if (propsBackgroundComponent) { setBackgroundComponent(propsBackgroundComponent); } }, [setBackgroundComponent, propsBackgroundComponent]); useEffect(() => { setStateModalNode(modalNode.current); }, [setStateModalNode, modalNode]); return ( {children}
); } ModalProvider.propTypes = { backgroundComponent: PropTypes.oneOfType([ PropTypes.element, PropTypes.object ]) }; export default ModalProvider; ================================================ FILE: src/baseStyles.jsx ================================================ import styled from 'styled-components' export const BaseModalBackground = styled.div` display: flex; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: 30; background-color: rgba(0, 0, 0, 0.5); align-items: center; justify-content: center; ` ================================================ FILE: src/context.jsx ================================================ import React from 'react' export const { Provider, Consumer } = React.createContext({}) ================================================ FILE: src/index.jsx ================================================ import { BaseModalBackground } from "./baseStyles"; import ModalProvider from "./ModalProvider"; import Modal from "./Modal"; export { Modal as default, ModalProvider, BaseModalBackground }; ================================================ FILE: tests/Modal.test.jsx ================================================ import "@testing-library/jest-dom/extend-expect"; import React, { useState } from "react"; import { act } from "react-dom/test-utils"; import styled from "styled-components"; import { render, fireEvent, waitFor } from "@testing-library/react"; import Modal, { ModalProvider } from "../src"; function StatefulModal(props) { const { isOpen: propsIsOpen, ...rest } = props; const [isOpen, setIsOpen] = useState(propsIsOpen); return (
Hello world
); } function renderWithProvider(modalProps = {}, providerProps = {}) { const finalModalProps = { isOpen: false, onBackgroundClick: jest.fn(), onEscapeKeyDown: jest.fn(), ...modalProps }; return render( ); } describe("", () => { it("renders nothing when not open", () => { const { queryByText } = renderWithProvider(); expect(queryByText("Hello world")).toBeNull(); }); it("renders children when open", () => { const { getByText } = renderWithProvider({ isOpen: true }); expect(getByText("Hello world")).toBeTruthy(); }); it("calls onBackgroundClick when the background is clicked", () => { const spy = jest.fn(); const { getByTestId } = renderWithProvider({ onBackgroundClick: spy, isOpen: true, backgroundProps: { "data-testid": "background" } }); fireEvent.click(getByTestId("background")); expect(spy.mock.calls.length).toBe(1); }); it("calls onEscapeKeydown when the escape key is pressed", () => { const spy = jest.fn(); renderWithProvider({ isOpen: true, onEscapeKeydown: spy }); fireEvent.keyDown(document, { key: "Escape" }); expect(spy.mock.calls.length).toBe(1); }); it("calls beforeOpen() before it opens", () => { const spy = jest.fn(); const sleeper = jest.fn(); const { getByTestId } = renderWithProvider({ beforeOpen: spy, beforeClose: sleeper }); fireEvent.click(getByTestId("button")); expect(spy.mock.calls.length).toBe(1); expect(sleeper).not.toHaveBeenCalled(); }); it("calls beforeOpen() before it opens and waits to call afterOpen() if it returns a promise", async () => { const spy = jest.fn( () => new Promise((resolve) => setTimeout(act(resolve), 100)) ); const afterOpenSpy = jest.fn(); const sleeper = jest.fn(); const { getByTestId } = renderWithProvider({ beforeOpen: spy, afterOpen: afterOpenSpy, beforeClose: sleeper }); fireEvent.click(getByTestId("button")); expect(spy.mock.calls.length).toBe(1); await waitFor(() => expect(afterOpenSpy.mock.calls.length).toBe(1)); expect(sleeper).not.toHaveBeenCalled(); }); it("calls afterOpen() after it opens", () => { const spy = jest.fn(); const sleeper = jest.fn(); const { getByTestId } = renderWithProvider({ afterOpen: spy, afterClose: sleeper }); fireEvent.click(getByTestId("button")); expect(spy.mock.calls.length).toBe(1); expect(sleeper).not.toHaveBeenCalled(); }); it("calls beforeClose() before it closes", () => { const spy = jest.fn(); const sleeper = jest.fn(); const { getByTestId } = renderWithProvider({ isOpen: true, beforeClose: spy, beforeOpen: sleeper }); fireEvent.click(getByTestId("button")); expect(spy.mock.calls.length).toBe(1); expect(sleeper.mock.calls.length).toBe(1); }); it("calls beforeClose() before it closes and waits to call afterClose() if it returns a promise", async () => { const spy = jest.fn( () => new Promise((resolve) => setTimeout(act(resolve), 100)) ); const afterCloseSpy = jest.fn(); const sleeper = jest.fn(); const { getByTestId } = renderWithProvider({ isOpen: true, beforeClose: spy, afterClose: afterCloseSpy, beforeOpen: sleeper }); fireEvent.click(getByTestId("button")); expect(spy.mock.calls.length).toBe(1); await waitFor(() => expect(afterCloseSpy.mock.calls.length).toBe(1)); expect(sleeper.mock.calls.length).toBe(1); }); it("calls afterClose() after it closes", () => { const spy = jest.fn(); const sleeper = jest.fn(); const { getByTestId } = renderWithProvider({ isOpen: true, afterClose: spy, afterOpen: sleeper }); fireEvent.click(getByTestId("button")); expect(spy.mock.calls.length).toBe(1); expect(sleeper.mock.calls.length).toBe(1); }); it("passes background props to background", () => { const Background = styled.div` background: ${(props) => props.color || "green"}; `; const { getByTestId } = renderWithProvider( { isOpen: true, backgroundProps: { color: "blue", "data-testid": "background" } }, { backgroundComponent: Background } ); expect(getByTestId("background")).toHaveStyle(`background: blue`); }); }); describe("Modal.styled()", () => { it("returns to a instance", () => { const StyledModal = Modal.styled` background-color: green; `; const { getByTestId } = render( ); expect(getByTestId("modal")).toHaveStyle(`background-color: green`); }); });