Repository: hiradary/reoverlay Branch: master Commit: 3eb999b7e931 Files: 37 Total size: 56.5 KB Directory structure: gitextract_h47_vi43/ ├── .github/ │ └── workflows/ │ └── ci.yml ├── .gitignore ├── .prettierrc ├── .vscode/ │ └── settings.json ├── LICENSE ├── README.md ├── demo/ │ ├── index.html │ ├── package.json │ ├── src/ │ │ ├── App.tsx │ │ ├── Icon.tsx │ │ ├── main.tsx │ │ └── styles.css │ ├── tsconfig.json │ └── vite.config.ts ├── eslint.config.mjs ├── package.json ├── pnpm-workspace.yaml ├── src/ │ ├── ModalContainer.tsx │ ├── ModalWrapper.css │ ├── ModalWrapper.tsx │ ├── Reoverlay.ts │ ├── constants/ │ │ └── index.ts │ ├── index.ts │ ├── types.ts │ └── utils/ │ ├── eventManager.ts │ ├── index.ts │ ├── utils.ts │ └── validator.ts ├── tests/ │ ├── ModalContainer.test.tsx │ ├── ModalWrapper.test.tsx │ ├── Reoverlay.test.tsx │ └── package-smoke.test.ts ├── tsconfig.build.json ├── tsconfig.json ├── tsup.config.ts ├── vitest.config.ts └── vitest.setup.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: pull_request: push: branches: - master - main jobs: checks: name: Checks runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v5 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: 10.30.0 - name: Setup Node uses: actions/setup-node@v5 with: node-version: 24 cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Lint run: pnpm lint - name: Typecheck run: pnpm typecheck - name: Test run: pnpm test - name: Build package and demo run: pnpm build:all ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /**/node_modules /.pnp .pnp.js # testing /coverage # production /dist /lib /demo/dist # misc .DS_Store .env.local .env.development.local .env.test.local .env.production.local npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* ================================================ FILE: .prettierrc ================================================ { "trailingComma": "es5", "tabWidth": 2, "semi": false, "singleQuote": true, "printWidth": 100 } ================================================ FILE: .vscode/settings.json ================================================ { "git.ignoreLimitWarning": true } ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2020 Hirad Arshadi Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 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 OR COPYRIGHT HOLDERS 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. ================================================ FILE: README.md ================================================ # Reoverlay A tiny, typed modal manager for React. Reoverlay gives you one top-level `ModalContainer` and a small imperative API for opening, stacking, and closing modals from anywhere in your app. [![Version](https://img.shields.io/npm/v/reoverlay)](https://www.npmjs.com/package/reoverlay) [![Downloads](https://img.shields.io/npm/dw/reoverlay)](https://www.npmjs.com/package/reoverlay) [![License](https://img.shields.io/npm/l/reoverlay)](LICENSE) ## Install ```bash pnpm add reoverlay ``` ```bash npm install reoverlay yarn add reoverlay ``` ## Quick Start Mount `ModalContainer` once near the root of your app. ```tsx import { ModalContainer } from 'reoverlay' export function App() { return ( <> ) } ``` Create a modal. `ModalWrapper` is optional, but it provides the default backdrop, animations, outside-click close behavior, and Escape close behavior. ```tsx import { ModalWrapper, Reoverlay } from 'reoverlay' import 'reoverlay/ModalWrapper.css' type ConfirmModalProps = { message: string onConfirm: () => void } export function ConfirmModal({ message, onConfirm }: ConfirmModalProps) { return (

{message}

) } ``` Open the modal directly. ```tsx import { Reoverlay } from 'reoverlay' import { ConfirmModal } from './ConfirmModal' Reoverlay.showModal(ConfirmModal, { message: 'Delete this post?', onConfirm: () => { Reoverlay.hideModal() }, }) ``` ## Named Modals You can configure modal names once and open them later by string. This is useful for app-wide modals, interceptors, and places where importing the modal component would be awkward. ```tsx import { ModalContainer, Reoverlay } from 'reoverlay' import { AuthModal, ConfirmModal } from './modals' Reoverlay.config([ { name: 'AuthModal', component: AuthModal }, { name: 'ConfirmModal', component: ConfirmModal }, ]) export function App() { return ( <> ) } ``` ```tsx Reoverlay.showModal('ConfirmModal', { message: 'Archive this item?', }) ``` ## API ### `Reoverlay.config(configData)` Registers named modals. ```ts type ModalConfigItem = { name: string component: React.ElementType | React.ReactElement } ``` Names must be unique within a single config call. ### `Reoverlay.showModal(modal, props?)` Shows a modal. `modal` can be a configured string name, a React component, or a React element. `props` are passed to the modal when it renders. ```tsx Reoverlay.showModal(MyModal, { title: 'Hello' }) Reoverlay.showModal() Reoverlay.showModal('MyModal', { title: 'Hello' }) ``` ### `Reoverlay.hideModal(modalName?)` Hides a modal. When no name is provided, the last visible modal is hidden. When a name is provided, that configured modal is hidden. ```ts Reoverlay.hideModal() Reoverlay.hideModal('ConfirmModal') ``` ### `Reoverlay.hideAll()` Closes every active modal. ```ts Reoverlay.hideAll() ``` ## `ModalWrapper` `ModalWrapper` is a small default shell. You can skip it and render your own modal UI if you only want Reoverlay's state orchestration. ```tsx import type { ModalWrapperProps } from 'reoverlay' ``` | Prop | Type | Default | | --------------------------- | ------------------------------------------------------------------------------------------------------------- | ----------------------------- | | `animation` | `'fade' \| 'zoom' \| 'flip' \| 'door' \| 'rotate' \| 'slideUp' \| 'slideDown' \| 'slideLeft' \| 'slideRight'` | `'fade'` | | `wrapperClassName` | `string` | `''` | | `contentContainerClassName` | `string` | `''` | | `onClose` | `(event) => void` | `() => Reoverlay.hideModal()` | | `closeOnEscape` | `boolean` | `true` | | `aria-label` | `string` | `undefined` | | `aria-labelledby` | `string` | `undefined` | | `aria-describedby` | `string` | `undefined` | | `role` | `'dialog' \| 'alertdialog'` | `'dialog'` | The preferred CSS import is: ```ts import 'reoverlay/ModalWrapper.css' ``` The legacy import path is still supported: ```ts import 'reoverlay/lib/ModalWrapper.css' ``` ## Development This repo uses pnpm workspaces. ```bash pnpm install pnpm dev ``` Useful scripts: ```bash pnpm lint pnpm typecheck pnpm test pnpm build:package pnpm build:demo pnpm build:all ``` The demo lives in `demo/` and builds with Vite for GitHub Pages under `/reoverlay/`. ## Release Checklist 1. Update the version in `package.json`. 2. Run `pnpm install --frozen-lockfile`. 3. Run `pnpm lint`, `pnpm typecheck`, `pnpm test`, and `pnpm build:all`. 4. Inspect the package contents with `npm pack --dry-run`. 5. Publish with `pnpm publish --otp ` if your npm account requires 2FA. 6. Deploy the demo with `pnpm --filter reoverlay-demo deploy`. ## License [MIT](LICENSE) ================================================ FILE: demo/index.html ================================================ Reoverlay demo
================================================ FILE: demo/package.json ================================================ { "name": "reoverlay-demo", "private": true, "version": "0.0.0", "type": "module", "scripts": { "build": "vite build", "deploy": "vite build && gh-pages -d dist", "dev": "vite --host 0.0.0.0", "preview": "vite preview", "typecheck": "tsc --noEmit" }, "dependencies": { "reoverlay": "workspace:*", "react": "^19.2.5", "react-dom": "^19.2.5" }, "devDependencies": { "@vitejs/plugin-react": "^6.0.1", "typescript": "^6.0.3", "vite": "^8.0.10" } } ================================================ FILE: demo/src/App.tsx ================================================ import { ModalContainer, ModalWrapper, Reoverlay, type ModalAnimation } from 'reoverlay' import 'reoverlay/ModalWrapper.css' import logo from './assets/logo.svg' import myPhoto from './assets/me.jpeg' import Icon from './Icon' const animationTypes: ModalAnimation[] = [ 'fade', 'zoom', 'flip', 'door', 'rotate', 'slideUp', 'slideDown', 'slideLeft', 'slideRight', ] const installationCode = ` pnpm add reoverlay // or if you prefer npm npm install reoverlay ` type DemoModalProps = { animation: ModalAnimation } const Code = ({ code }: { code: string }) => (
      {code.trim()}
    
) const Modal3 = ({ animation }: DemoModalProps) => { return (

#3 Modal. Ok that's enough 😆 (Though you can keep stacking up as you wish.)

) } const Modal2 = ({ animation }: DemoModalProps) => { return (

#2 Modal. It's getting dark here 🌗. Wanna see the third one?

) } const Modal1 = ({ animation }: DemoModalProps) => { return (

#1 Modal. So sweet! ❤️. Wanna see more?

) } const App = () => { const showModal = (animation: ModalAnimation) => { Reoverlay.showModal(Modal1, { animation }) } return (
Reoverlay

Reoverlay

The missing solution for managing modals in React.

Animation types

There are quite a few preset animations. You can create your own custom animation too!

{animationTypes.map((type) => ( ))}

Usage, Props, etc.

You can find more information on{' '} github .

Hirad Arshadi

Made with{' '} ❤️ {' '} for the react community

@hiradary
) } export default App ================================================ FILE: demo/src/Icon.tsx ================================================ type IconProps = { className?: string name: 'donation' | 'github' | 'twitter' } const Icon = ({ className, name }: IconProps) => { switch (name) { case 'github': return ( ) case 'donation': return ( ) case 'twitter': return ( ) } } export default Icon ================================================ FILE: demo/src/main.tsx ================================================ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import App from './App' import './styles.css' createRoot(document.getElementById('root') as HTMLElement).render( ) ================================================ FILE: demo/src/styles.css ================================================ @font-face { font-family: 'ProductSans'; font-weight: 400; src: local('Product Sans'), local('ProductSans-Regular'), url('./fonts/ProductSansRegular.woff2') format('woff2'); font-style: normal; } @font-face { font-family: 'ProductSans'; font-weight: 700; src: local('Product Sans Bold'), local('ProductSans-Bold'), url('./fonts/ProductSansBold.woff2') format('woff2'); font-style: normal; } :root { --color-blue: #2578ff; --color-blue-dark-primary: #263b5d; --color-blue-dark-secondary: #435b81; --color-gray: #b1b8c5; --color-gray-light: rgba(177, 184, 197, 0.15); --size-huge-title: 2em; --size-title: 1.5625em; } html, body, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite, code, del, dfn, em, img, ins, kbd, q, s, samp, small, strike, strong, sub, sup, tt, var, b, u, i, center, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td, article, aside, canvas, details, embed, figure, figcaption, footer, header, hgroup, menu, nav, output, ruby, section, summary, time, mark, audio, video { margin: 0; padding: 0; border: 0; font-size: 100%; font: inherit; vertical-align: baseline; } article, aside, details, figcaption, figure, footer, header, hgroup, menu, nav, section { display: block; } body { line-height: 1; } ol, ul { list-style: none; } blockquote, q { quotes: none; } blockquote::before, blockquote::after, q::before, q::after { content: ''; } table { border-collapse: collapse; border-spacing: 0; } * { box-sizing: border-box; } body { font-family: 'ProductSans', sans-serif; font-weight: normal; } a { -webkit-tap-highlight-color: transparent; color: inherit; text-decoration: none; } button { -webkit-tap-highlight-color: transparent; border: none; outline: none; cursor: pointer; font-family: inherit; } main { width: 100%; display: flex; justify-content: center; align-items: center; padding: 20vh 0; } .container { display: flex; flex-direction: column; align-items: center; width: 100%; max-width: 40em; } .header { display: flex; flex-direction: column; align-items: center; width: 100%; } .header .header__logo { width: 221px; height: 144px; } .header .header__title { color: var(--color-blue-dark-primary); font-size: var(--size-huge-title); font-weight: bold; padding-top: 0.5em; } .header .header__description { color: var(--color-gray); padding-top: 1.5em; text-align: center; line-height: 1.5em; } .header .header__buttonContainer { display: flex; align-items: center; justify-content: center; padding: 2.625em 0 1.5em; } .header .header__githubButton { background-color: var(--color-blue); height: 3.75em; padding: 0 3em; border-radius: 1em; color: white; transition: box-shadow 0.35s; user-select: none; margin: 0 1em; display: flex; align-items: center; } .header .header__githubButtonText { padding-right: 0.5em; white-space: nowrap; } .header .header__githubButton:hover { box-shadow: 0 8px 15px rgba(37, 120, 255, 0.2); } .header .header__donationButton { border: 1px solid #d7dee9; height: 3.75em; padding: 0 2.5em; border-radius: 1em; margin: 0 1em; display: flex; align-items: center; color: var(--color-blue-dark-primary); user-select: none; transition: box-shadow 0.35s; } .header .header__donationButtonText { padding-right: 0.5em; white-space: nowrap; } .Code { width: 100%; } .Code pre { overflow: auto; border-radius: 1em; background: #f6f8fb; color: var(--color-blue-dark-primary); line-height: 1.5; padding: 1.5em; } .section { margin-top: 3.75em; display: flex; flex-direction: column; width: 100%; } .section .section__title { color: var(--color-blue-dark-primary); font-size: var(--size-title); font-weight: bold; margin-bottom: 0.2em; text-align: left; } .section .section__description { color: var(--color-gray); line-height: 1.5em; } .section .section__description .section__link { color: var(--color-blue-dark-secondary); } .section .section__animationTypesContainer { width: 100%; display: flex; flex-wrap: wrap; justify-content: space-between; padding-top: 1.5em; } .section .section__animationTypeContainer { width: 28%; margin-bottom: 2em; background: transparent; padding: 0; } .section .section__animationType { background-color: var(--color-gray-light); border-radius: 1em; text-align: center; padding: 1em 0; color: var(--color-blue-dark-secondary); user-select: none; cursor: pointer; -webkit-tap-highlight-color: transparent; display: block; } .modalContent { padding: 2em 5em; display: flex; flex-direction: column; align-items: center; } .modalContent .modalContent__title { font-weight: bold; font-size: 1.5em; } .modalContent .modalContent__buttonContainer { display: flex; align-items: center; justify-content: center; margin-top: 2em; } .modalContent .modalContent__buttonContainer button { border: 1px solid #d7dee9; padding: 1em 2.5em; border-radius: 1em; margin: 0 1em; display: flex; align-items: center; color: var(--color-blue-dark-primary); user-select: none; background-color: white; transition: 0.3s background-color; } .modalContent .modalContent__buttonContainer button:hover { background-color: var(--color-gray-light); } .footer { width: 100%; display: flex; align-items: center; flex-direction: column; margin-top: 3.75em; } .footer .footer__profilePhoto { width: 6.25em; height: 6.25em; border-radius: 100%; margin-bottom: 1.25em; } .footer .footer__intentionText { color: var(--color-gray); } .footer .footer__twitterIcon { width: 1em; } .footer .footer__twitterContainer { display: flex; align-items: center; margin-top: 1em; } .footer .footer__twitterHandle { padding-left: 0.2em; color: var(--color-blue-dark-primary); } @media (max-width: 768px) { .container { padding: 0 1.25em; } .modalContent { padding: 2em 3em; } .modalContent .modalContent__buttonContainer { flex-direction: column; } .modalContent .modalContent__buttonContainer button:not(:last-child) { margin-bottom: 1em; } } @media (max-width: 500px) { .header .header__buttonContainer { flex-direction: column; } .header .header__githubButton { margin: 0 0 1em; } } ================================================ FILE: demo/tsconfig.json ================================================ { "extends": "../tsconfig.json", "compilerOptions": { "allowImportingTsExtensions": true, "noEmit": true, "paths": { "reoverlay": ["../src/index.ts"], "reoverlay/ModalWrapper.css": ["../src/ModalWrapper.css"] }, "types": ["vite/client"] }, "include": ["src", "vite.config.ts"] } ================================================ FILE: demo/vite.config.ts ================================================ import path from 'node:path' import { fileURLToPath } from 'node:url' import react from '@vitejs/plugin-react' import { defineConfig } from 'vite' const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') export default defineConfig({ base: '/reoverlay/', plugins: [react()], resolve: { alias: [ { find: /^reoverlay\/ModalWrapper.css$/, replacement: path.resolve(root, 'src/ModalWrapper.css'), }, { find: 'reoverlay', replacement: path.resolve(root, 'src/index.ts'), }, ], }, }) ================================================ FILE: eslint.config.mjs ================================================ import js from '@eslint/js' import tseslint from 'typescript-eslint' export default tseslint.config( { ignores: ['coverage', 'dist', 'lib', 'demo/dist'], }, js.configs.recommended, ...tseslint.configs.recommended, { files: ['**/*.{ts,tsx}'], languageOptions: { parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname, }, }, rules: { '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-empty-object-type': 'off', }, } ) ================================================ FILE: package.json ================================================ { "name": "reoverlay", "version": "1.1.0", "description": "A tiny, typed modal manager for React.", "license": "MIT", "author": "Hirad Arshadi ", "repository": { "type": "git", "url": "https://github.com/hiradary/reoverlay" }, "bugs": { "url": "https://github.com/hiradary/reoverlay/issues" }, "homepage": "https://hiradary.github.io/reoverlay", "keywords": [ "modal", "overlay", "react", "react-modal", "react-overlay", "reoverlay" ], "packageManager": "pnpm@10.30.0", "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" }, "./ModalWrapper.css": "./dist/ModalWrapper.css", "./lib/ModalWrapper.css": "./lib/ModalWrapper.css", "./package.json": "./package.json" }, "files": [ "dist", "lib", "README.md", "LICENSE" ], "sideEffects": [ "**/*.css" ], "scripts": { "build": "pnpm build:package", "build:all": "pnpm build:package && pnpm build:demo", "build:demo": "pnpm --filter reoverlay-demo build", "build:package": "tsup", "clean": "rm -rf dist lib coverage demo/dist", "dev": "pnpm --filter reoverlay-demo dev", "format": "prettier --write .", "lint": "eslint .", "prepublishOnly": "pnpm lint && pnpm typecheck && pnpm test && pnpm build:package", "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit && pnpm --filter reoverlay-demo typecheck" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" }, "devDependencies": { "@eslint/js": "^10.0.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/node": "^25.6.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", "eslint": "^10.2.1", "gh-pages": "^6.3.0", "jsdom": "^29.0.2", "prettier": "^3.8.3", "react": "^19.2.5", "react-dom": "^19.2.5", "tsup": "^8.5.1", "typescript": "^6.0.3", "typescript-eslint": "^8.59.0", "vite": "^8.0.10", "vitest": "^4.1.5" } } ================================================ FILE: pnpm-workspace.yaml ================================================ packages: - demo ================================================ FILE: src/ModalContainer.tsx ================================================ import { Fragment, cloneElement, isValidElement, useEffect, useState } from 'react' import { EVENT } from './constants' import type { ActiveModal } from './types' import { eventManager } from './utils' const ModalContainer = () => { const [modals, setModals] = useState([]) useEffect(() => { const unsubscribe = eventManager.on(EVENT.CHANGE_MODAL, setModals) return unsubscribe }, []) return (
{modals.map(({ modalKey, component, props }) => { if (isValidElement(component)) { return {cloneElement(component, props)} } const Component = component return ( ) })}
) } export default ModalContainer ================================================ FILE: src/ModalWrapper.css ================================================ .reOverlay .reOverlay__modalWrapper { width: 100%; height: 100vh; height: 100dvh; position: fixed; top: 0; left: 0; z-index: 999; background-color: rgba(0, 0, 0, 0.6); display: -webkit-box; display: -ms-flexbox; display: flex; -webkit-box-align: center; -ms-flex-align: center; align-items: center; -webkit-box-pack: center; -ms-flex-pack: center; justify-content: center; -webkit-animation: ro-fade 0.3s forwards; animation: ro-fade 0.3s forwards; } .reOverlay .reOverlay__modalWrapper, .reOverlay .reOverlay__modalWrapper * { box-sizing: border-box; } .reOverlay .reOverlay__modalWrapper .reOverlay__modalContainer { background-color: white; } .reOverlay .reOverlay__modalWrapper.-ro-zoom .reOverlay__modalContainer { -webkit-animation: ro-zoom 0.3s cubic-bezier(0.4, 0, 0, 1.5) forwards; animation: ro-zoom 0.3s cubic-bezier(0.4, 0, 0, 1.5) forwards; } .reOverlay .reOverlay__modalWrapper.-ro-slideDown .reOverlay__modalContainer { -webkit-animation: ro-slideDown 0.3s cubic-bezier(0.4, 0, 0, 1.5) forwards; animation: ro-slideDown 0.3s cubic-bezier(0.4, 0, 0, 1.5) forwards; } .reOverlay .reOverlay__modalWrapper.-ro-slideUp .reOverlay__modalContainer { -webkit-animation: ro-slideUp 0.3s cubic-bezier(0.4, 0, 0, 1.5) forwards; animation: ro-slideUp 0.3s cubic-bezier(0.4, 0, 0, 1.5) forwards; } .reOverlay .reOverlay__modalWrapper.-ro-slideLeft .reOverlay__modalContainer { -webkit-animation: ro-slideLeft 0.3s cubic-bezier(0.4, 0, 0, 1.5) forwards; animation: ro-slideLeft 0.3s cubic-bezier(0.4, 0, 0, 1.5) forwards; } .reOverlay .reOverlay__modalWrapper.-ro-slideRight .reOverlay__modalContainer { -webkit-animation: ro-slideRight 0.3s cubic-bezier(0.4, 0, 0, 1.5) forwards; animation: ro-slideRight 0.3s cubic-bezier(0.4, 0, 0, 1.5) forwards; } .reOverlay .reOverlay__modalWrapper.-ro-flip .reOverlay__modalContainer { -webkit-animation: ro-flip 0.3s forwards ease-in; animation: ro-flip 0.3s forwards ease-in; -webkit-backface-visibility: visible !important; backface-visibility: visible !important; } .reOverlay .reOverlay__modalWrapper.-ro-rotate .reOverlay__modalContainer { -webkit-animation: ro-rotate 0.3s forwards ease-out; animation: ro-rotate 0.3s forwards ease-out; -webkit-transform-origin: center; -ms-transform-origin: center; transform-origin: center; } .reOverlay .reOverlay__modalWrapper.-ro-door .reOverlay__modalContainer { -webkit-animation: ro-door 0.3s cubic-bezier(0.4, 0, 0, 1.5) forwards; animation: ro-door 0.3s cubic-bezier(0.4, 0, 0, 1.5) forwards; } /* Keyframes */ @-webkit-keyframes ro-fade { from { opacity: 0; } to { opacity: 1; } } @keyframes ro-fade { from { opacity: 0; } to { opacity: 1; } } @-webkit-keyframes ro-zoom { from { -webkit-transform: scale3d(0.3, 0.3, 0.3); transform: scale3d(0.3, 0.3, 0.3); } to { -webkit-transform: scale3d(1, 1, 1); transform: scale3d(1, 1, 1); } } @keyframes ro-zoom { from { -webkit-transform: scale3d(0.3, 0.3, 0.3); transform: scale3d(0.3, 0.3, 0.3); } to { -webkit-transform: scale3d(1, 1, 1); transform: scale3d(1, 1, 1); } } @-webkit-keyframes ro-slideDown { from { -webkit-transform: translate3d(0, -2rem, 0); transform: translate3d(0, -2rem, 0); } to { -webkit-transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0); } } @keyframes ro-slideDown { from { -webkit-transform: translate3d(0, -2rem, 0); transform: translate3d(0, -2rem, 0); } to { -webkit-transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0); } } @-webkit-keyframes ro-slideUp { from { -webkit-transform: translate3d(0, 2rem, 0); transform: translate3d(0, 2rem, 0); } to { -webkit-transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0); } } @keyframes ro-slideUp { from { -webkit-transform: translate3d(0, 2rem, 0); transform: translate3d(0, 2rem, 0); } to { -webkit-transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0); } } @-webkit-keyframes ro-slideLeft { from { -webkit-transform: translate3d(-2rem, 0, 0); transform: translate3d(-2rem, 0, 0); } to { -webkit-transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0); } } @keyframes ro-slideLeft { from { -webkit-transform: translate3d(-2rem, 0, 0); transform: translate3d(-2rem, 0, 0); } to { -webkit-transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0); } } @-webkit-keyframes ro-slideRight { from { -webkit-transform: translate3d(2rem, 0, 0); transform: translate3d(2rem, 0, 0); } to { -webkit-transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0); } } @keyframes ro-slideRight { from { -webkit-transform: translate3d(2rem, 0, 0); transform: translate3d(2rem, 0, 0); } to { -webkit-transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0); } } @-webkit-keyframes ro-flip { from { -webkit-transform: perspective(18rem) rotate3d(1, 0, 0, 50deg); transform: perspective(18rem) rotate3d(1, 0, 0, 50deg); } 70% { -webkit-transform: perspective(18rem) rotate3d(1, 0, 0, -15deg); transform: perspective(18rem) rotate3d(1, 0, 0, -15deg); } to { -webkit-transform: perspective(18rem); transform: perspective(18rem); } } @keyframes ro-flip { from { -webkit-transform: perspective(18rem) rotate3d(1, 0, 0, 50deg); transform: perspective(18rem) rotate3d(1, 0, 0, 50deg); } 70% { -webkit-transform: perspective(18rem) rotate3d(1, 0, 0, -15deg); transform: perspective(18rem) rotate3d(1, 0, 0, -15deg); } to { -webkit-transform: perspective(18rem); transform: perspective(18rem); } } @-webkit-keyframes ro-rotate { from { -webkit-transform: rotate3d(0, 0, 1, -180deg) scale3d(0.3, 0.3, 0.3); transform: rotate3d(0, 0, 1, -180deg) scale3d(0.3, 0.3, 0.3); } to { -webkit-transform: rotate3d(0, 0, 1, 0deg) scale3d(1, 1, 1); transform: rotate3d(0, 0, 1, 0deg) scale3d(1, 1, 1); } } @keyframes ro-rotate { from { -webkit-transform: rotate3d(0, 0, 1, -180deg) scale3d(0.3, 0.3, 0.3); transform: rotate3d(0, 0, 1, -180deg) scale3d(0.3, 0.3, 0.3); } to { -webkit-transform: rotate3d(0, 0, 1, 0deg) scale3d(1, 1, 1); transform: rotate3d(0, 0, 1, 0deg) scale3d(1, 1, 1); } } @-webkit-keyframes ro-door { from { -webkit-transform: scale3d(0, 1, 1); transform: scale3d(0, 1, 1); } to { -webkit-transform: scale3d(1, 1, 1); transform: scale3d(1, 1, 1); } } @keyframes ro-door { from { -webkit-transform: scale3d(0, 1, 1); transform: scale3d(0, 1, 1); } to { -webkit-transform: scale3d(1, 1, 1); transform: scale3d(1, 1, 1); } } @media (prefers-reduced-motion: reduce) { .reOverlay .reOverlay__modalWrapper, .reOverlay .reOverlay__modalWrapper .reOverlay__modalContainer { -webkit-animation-duration: 1ms; animation-duration: 1ms; } } ================================================ FILE: src/ModalWrapper.tsx ================================================ import { useEffect, useRef } from 'react' import type React from 'react' import Reoverlay from './Reoverlay' import type { ModalWrapperProps } from './types' const ModalWrapper = ({ 'aria-describedby': ariaDescribedBy, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, animation = 'fade', children = null, closeOnEscape = true, contentContainerClassName = '', onClose = () => { Reoverlay.hideModal() }, role = 'dialog', wrapperClassName = '', }: ModalWrapperProps) => { const wrapperElement = useRef(null) useEffect(() => { if (!closeOnEscape) return undefined const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { onClose(event) } } document.addEventListener('keydown', handleKeyDown) return () => { document.removeEventListener('keydown', handleKeyDown) } }, [closeOnEscape, onClose]) const handleClickOutside = (event: React.MouseEvent) => { if (event.target === wrapperElement.current) { onClose(event) } } return (
{children}
) } export default ModalWrapper ================================================ FILE: src/Reoverlay.ts ================================================ import { EVENT, VALIDATE } from './constants' import type { ActiveModal, ModalConfigItem, ModalProps, ModalRenderable } from './types' import { eventManager, getLastElement, validate } from './utils' type ModalSnapshot

= Omit, 'modalKey'> let modalId = 0 const createModalKey = () => { modalId += 1 return `reoverlay-${modalId}` } const Reoverlay = { modals: new Map(), snapshots: new Map>(), config(configData: ModalConfigItem[] = []) { validate(VALIDATE.CONFIG, configData) configData.forEach((item) => { this.modals.set(item.name, item.component) }) }, showModal

( modal: ModalRenderable

| string, props = {} as P ) { const modalType = validate(VALIDATE.SHOW_MODAL, modal) if (modalType === 'string') { const modalKey = modal as string const modalElement = this.modals.get(modalKey) if (!modalElement) { throw new Error( `Reoverlay: Modal not found. Make sure "${modalKey}" has been passed to Reoverlay.config().` ) } this.applyModal({ component: modalElement, modalKey, props, type: EVENT.SHOW_MODAL, }) return } this.applyModal({ component: modal as ModalRenderable

, modalKey: createModalKey(), props, type: EVENT.SHOW_MODAL, }) }, getSnapshotsArray() { return Array.from(this.snapshots.entries()).map(([modalKey, value]) => ({ modalKey, ...value, })) }, hideModal(modal: string | null = null) { if (modal) { validate(VALIDATE.HIDE_MODAL, modal) const modalKey = modal const snapshot = this.snapshots.get(modalKey) if (!snapshot) { throw new Error("Reoverlay: Snapshot not found. You're trying to hide a missing modal.") } this.applyModal({ ...snapshot, modalKey, type: EVENT.HIDE_MODAL, }) return } const lastSnapshot = getLastElement(this.getSnapshotsArray()) ?? null if (lastSnapshot) { this.applyModal({ ...lastSnapshot, type: EVENT.HIDE_MODAL }) return } console.error("Reoverlay: There's no active modal to be hidden.") }, hideAll() { this.applyModal({ type: EVENT.HIDE_ALL }) }, applyModal({ component, modalKey, props, type, }: Partial> & { type: (typeof EVENT)[keyof typeof EVENT] }) { switch (type) { case EVENT.SHOW_MODAL: if (component && modalKey) { this.snapshots.set(modalKey, { component, props: props ?? {} }) } break case EVENT.HIDE_ALL: this.snapshots.clear() break default: if (modalKey) { this.snapshots.delete(modalKey) } break } eventManager.emit(EVENT.CHANGE_MODAL, this.getSnapshotsArray()) }, } export default Reoverlay ================================================ FILE: src/constants/index.ts ================================================ export const VALIDATE = { CONFIG: 'config', HIDE_MODAL: 'hide_modal', SHOW_MODAL: 'show_modal', } as const export const EVENT = { CHANGE_MODAL: 'change_modal', HIDE_ALL: 'hide_all', HIDE_MODAL: 'hide_modal', SHOW_MODAL: 'show_modal', } as const ================================================ FILE: src/index.ts ================================================ export { default as ModalContainer } from './ModalContainer' export { default as ModalWrapper } from './ModalWrapper' export { default as Reoverlay } from './Reoverlay' export type { ActiveModal, ModalAnimation, ModalCloseEvent, ModalComponent, ModalConfigItem, ModalElement, ModalProps, ModalRenderable, ModalWrapperProps, } from './types' ================================================ FILE: src/types.ts ================================================ import type React from 'react' export type ModalProps = Record export type ModalComponent

= React.ElementType

export type ModalElement

= React.ReactElement

export type ModalRenderable

= ModalComponent

| ModalElement

export type ModalConfigItem

= { name: string component: ModalRenderable

} export type ModalAnimation = | 'fade' | 'zoom' | 'flip' | 'door' | 'rotate' | 'slideUp' | 'slideDown' | 'slideLeft' | 'slideRight' export type ModalCloseEvent = | React.MouseEvent | KeyboardEvent | React.KeyboardEvent export type ModalWrapperProps = { 'aria-describedby'?: string 'aria-label'?: string 'aria-labelledby'?: string animation?: ModalAnimation children?: React.ReactNode closeOnEscape?: boolean contentContainerClassName?: string onClose?: (event: ModalCloseEvent) => void role?: 'dialog' | 'alertdialog' wrapperClassName?: string } export type ActiveModal

= { component: ModalRenderable

modalKey: string props: P } ================================================ FILE: src/utils/eventManager.ts ================================================ type Listener = (payload: TPayload) => void const eventManager = (() => { const subscribers = new Map>>() const on = (eventName: string, callback: Listener) => { if (!subscribers.has(eventName)) { subscribers.set(eventName, new Set()) } const listeners = subscribers.get(eventName) listeners?.add(callback as Listener) return () => off(eventName, callback) } const off = (eventName?: string, callback?: Listener) => { if (!eventName) { subscribers.clear() return } if (!callback) { subscribers.delete(eventName) return } const listeners = subscribers.get(eventName) listeners?.delete(callback as Listener) if (listeners?.size === 0) { subscribers.delete(eventName) } } const emit = (eventName: string, payload: TPayload) => { subscribers.get(eventName)?.forEach((callback) => { callback(payload) }) } const listenerCount = (eventName: string) => subscribers.get(eventName)?.size ?? 0 return { emit, listenerCount, off, on, } })() export default eventManager ================================================ FILE: src/utils/index.ts ================================================ export { default as eventManager } from './eventManager' export * from './utils' export * from './validator' ================================================ FILE: src/utils/utils.ts ================================================ export const getLastElement = (array: TValue[]) => array[array.length - 1] export const isArrayUnique = (array: TValue[]) => new Set(array).size === array.length export const isString = (value: unknown): value is string => typeof value === 'string' || value instanceof String export const isModalLikeObject = (value: unknown) => typeof value === 'object' && value !== null && '$$typeof' in value ================================================ FILE: src/utils/validator.ts ================================================ import { isValidElement } from 'react' import { VALIDATE } from '../constants' import type { ModalConfigItem, ModalRenderable } from '../types' import { isArrayUnique, isModalLikeObject, isString } from './utils' type ValidationType = (typeof VALIDATE)[keyof typeof VALIDATE] export type ShowModalValidationResult = 'component' | 'string' const isRenderableModal = (value: unknown): value is ModalRenderable => typeof value === 'function' || isValidElement(value) || isModalLikeObject(value) export const validate = ( type: ValidationType, value: unknown ): boolean | ShowModalValidationResult => { switch (type) { case VALIDATE.CONFIG: { if (!Array.isArray(value)) { throw new Error( 'Reoverlay: Config data must be an array. Pass an array to Reoverlay.config().' ) } const configData = value as ModalConfigItem[] configData.forEach((item) => { if (!item.name || !item.component) { throw new Error( "Reoverlay: Each config item must contain a 'name' and 'component' property." ) } }) const names = configData.map((item) => item.name) if (!isArrayUnique(names)) { throw new Error('Reoverlay: Modal config names must be unique.') } return true } case VALIDATE.SHOW_MODAL: { const throwError = () => { throw new Error( "Reoverlay: Method 'showModal' requires a React component, React element, or configured modal name." ) } if (!value) throwError() if (isString(value)) return 'string' if (isRenderableModal(value)) return 'component' throwError() return false } case VALIDATE.HIDE_MODAL: { if (isString(value)) return true throw new Error( `Reoverlay: Method 'hideModal' accepts an optional string modal name, got ${typeof value}.` ) } default: return false } } ================================================ FILE: tests/ModalContainer.test.tsx ================================================ import { act, cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' import { EVENT } from '../src/constants' import { ModalContainer, Reoverlay } from '../src' import { eventManager } from '../src/utils' const TestModal = ({ label }: { label: string }) =>

{label}
afterEach(() => { act(() => { Reoverlay.hideAll() }) cleanup() }) describe('ModalContainer', () => { it('subscribes once and cleans up the exact change listener', () => { const { unmount } = render() expect(eventManager.listenerCount(EVENT.CHANGE_MODAL)).toBe(1) act(() => { Reoverlay.showModal(TestModal, { label: 'Stable listener' }) }) expect(screen.getByText('Stable listener')).toBeInTheDocument() expect(eventManager.listenerCount(EVENT.CHANGE_MODAL)).toBe(1) unmount() expect(eventManager.listenerCount(EVENT.CHANGE_MODAL)).toBe(0) }) }) ================================================ FILE: tests/ModalWrapper.test.tsx ================================================ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { ModalWrapper } from '../src' describe('ModalWrapper', () => { it('adds dialog semantics and custom classes', () => { render( Content ) const dialog = screen.getByRole('dialog', { name: 'Confirm action' }) expect(dialog).toHaveAttribute('aria-modal', 'true') expect(dialog).toHaveClass('-ro-slideUp') expect(dialog).toHaveClass('custom-wrapper') expect(dialog.firstElementChild).toHaveClass('custom-content') }) it('calls onClose when the wrapper backdrop is clicked', () => { const onClose = vi.fn() render( ) fireEvent.click(screen.getByRole('dialog', { name: 'Close from backdrop' })) fireEvent.click(screen.getByRole('button', { name: 'Inside' })) expect(onClose).toHaveBeenCalledTimes(1) }) it('calls onClose when Escape is pressed', () => { const onClose = vi.fn() render( Content ) fireEvent.keyDown(document, { key: 'Escape' }) expect(onClose).toHaveBeenCalledTimes(1) }) it('can disable Escape close behavior', () => { const onClose = vi.fn() render( Content ) fireEvent.keyDown(document, { key: 'Escape' }) expect(onClose).not.toHaveBeenCalled() }) }) ================================================ FILE: tests/Reoverlay.test.tsx ================================================ import { cleanup, render, screen, act } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { ModalContainer, Reoverlay } from '../src' const TestModal = ({ label }: { label: string }) =>
{label}
afterEach(() => { act(() => { Reoverlay.hideAll() }) cleanup() vi.restoreAllMocks() }) describe('Reoverlay', () => { it('renders a direct modal component with props', () => { render() act(() => { Reoverlay.showModal(TestModal, { label: 'Direct modal' }) }) expect(screen.getByText('Direct modal')).toBeInTheDocument() }) it('renders a configured modal by name', () => { render() Reoverlay.config([{ component: TestModal, name: 'NamedModal' }]) act(() => { Reoverlay.showModal('NamedModal', { label: 'Named modal' }) }) expect(screen.getByText('Named modal')).toBeInTheDocument() }) it('supports React elements passed directly', () => { render() act(() => { Reoverlay.showModal() }) expect(screen.getByText('Element modal')).toBeInTheDocument() }) it('hides the last modal by default', () => { render() act(() => { Reoverlay.showModal(TestModal, { label: 'First modal' }) Reoverlay.showModal(TestModal, { label: 'Second modal' }) Reoverlay.hideModal() }) expect(screen.getByText('First modal')).toBeInTheDocument() expect(screen.queryByText('Second modal')).not.toBeInTheDocument() }) it('hides a named modal while leaving other modals open', () => { render() Reoverlay.config([ { component: TestModal, name: 'FirstNamedModal' }, { component: TestModal, name: 'SecondNamedModal' }, ]) act(() => { Reoverlay.showModal('FirstNamedModal', { label: 'First named modal' }) Reoverlay.showModal('SecondNamedModal', { label: 'Second named modal' }) Reoverlay.hideModal('SecondNamedModal') }) expect(screen.getByText('First named modal')).toBeInTheDocument() expect(screen.queryByText('Second named modal')).not.toBeInTheDocument() }) it('hides all active modals', () => { render() act(() => { Reoverlay.showModal(TestModal, { label: 'First modal' }) Reoverlay.showModal(TestModal, { label: 'Second modal' }) Reoverlay.hideAll() }) expect(screen.queryByText('First modal')).not.toBeInTheDocument() expect(screen.queryByText('Second modal')).not.toBeInTheDocument() }) it('validates duplicate configured names', () => { expect(() => { Reoverlay.config([ { component: TestModal, name: 'DuplicateModal' }, { component: TestModal, name: 'DuplicateModal' }, ]) }).toThrow(/unique/i) }) it('throws for missing configured modals', () => { expect(() => { Reoverlay.showModal('MissingModal') }).toThrow(/not found/i) }) }) ================================================ FILE: tests/package-smoke.test.ts ================================================ import { createRequire } from 'node:module' import { execFileSync } from 'node:child_process' import { existsSync } from 'node:fs' import path from 'node:path' import { pathToFileURL } from 'node:url' import { beforeAll, describe, expect, it } from 'vitest' const require = createRequire(import.meta.url) const root = path.resolve(import.meta.dirname, '..') describe('package output', () => { beforeAll(() => { execFileSync('pnpm', ['build:package'], { cwd: root, stdio: 'inherit', }) }, 60_000) it('emits ESM, CommonJS, declarations, and CSS compatibility files', async () => { expect(existsSync(path.join(root, 'dist/index.js'))).toBe(true) expect(existsSync(path.join(root, 'dist/index.cjs'))).toBe(true) expect(existsSync(path.join(root, 'dist/index.d.ts'))).toBe(true) expect(existsSync(path.join(root, 'dist/ModalWrapper.css'))).toBe(true) expect(existsSync(path.join(root, 'lib/ModalWrapper.css'))).toBe(true) const esm = await import(pathToFileURL(path.join(root, 'dist/index.js')).href) const cjs = require('../dist/index.cjs') expect(esm.Reoverlay.showModal).toEqual(expect.any(Function)) expect(cjs.ModalContainer).toEqual(expect.any(Function)) }) }) ================================================ FILE: tsconfig.build.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "declaration": true, "declarationMap": true, "emitDeclarationOnly": false, "noEmit": false, "outDir": "dist", "types": [] }, "include": ["src"], "exclude": ["tests", "**/*.test.ts", "**/*.test.tsx"] } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "allowSyntheticDefaultImports": true, "declaration": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "isolatedModules": true, "jsx": "react-jsx", "ignoreDeprecations": "6.0", "lib": ["DOM", "DOM.Iterable", "ES2022"], "module": "ESNext", "moduleResolution": "Bundler", "noEmit": true, "noUncheckedIndexedAccess": true, "resolveJsonModule": true, "skipLibCheck": true, "strict": true, "target": "ES2022", "types": ["vitest/globals"] }, "include": [ "src", "tests", "tsup.config.ts", "vitest.config.ts", "vitest.setup.ts", "eslint.config.mjs" ] } ================================================ FILE: tsup.config.ts ================================================ import { copyFile, mkdir } from 'node:fs/promises' import { defineConfig } from 'tsup' const copyStyles = async () => { await mkdir('dist', { recursive: true }) await mkdir('lib', { recursive: true }) await copyFile('src/ModalWrapper.css', 'dist/ModalWrapper.css') await copyFile('src/ModalWrapper.css', 'lib/ModalWrapper.css') } export default defineConfig({ clean: true, dts: true, entry: ['src/index.ts'], external: ['react', 'react-dom'], format: ['esm', 'cjs'], minify: true, onSuccess: copyStyles, outDir: 'dist', outExtension({ format }) { return { js: format === 'cjs' ? '.cjs' : '.js', } }, sourcemap: true, splitting: false, target: 'es2020', treeshake: true, }) ================================================ FILE: vitest.config.ts ================================================ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { environment: 'jsdom', globals: true, setupFiles: ['./vitest.setup.ts'], }, }) ================================================ FILE: vitest.setup.ts ================================================ import '@testing-library/jest-dom/vitest'