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.
[](https://www.npmjs.com/package/reoverlay)
[](https://www.npmjs.com/package/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 (
<>
<Routes />
<ModalContainer />
</>
)
}
```
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 (
<ModalWrapper aria-label="Confirm action">
<p>{message}</p>
<button onClick={onConfirm} type="button">
Confirm
</button>
<button onClick={() => Reoverlay.hideModal()} type="button">
Cancel
</button>
</ModalWrapper>
)
}
```
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 (
<>
<Routes />
<ModalContainer />
</>
)
}
```
```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(<MyModal title="Hello" />)
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 <code>` if your npm account requires 2FA.
6. Deploy the demo with `pnpm --filter reoverlay-demo deploy`.
## License
[MIT](LICENSE)
================================================
FILE: demo/index.html
================================================
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Reoverlay demo: a tiny typed modal manager for React." />
<title>Reoverlay demo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
================================================
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 }) => (
<div className="Code">
<pre>
<code>{code.trim()}</code>
</pre>
</div>
)
const Modal3 = ({ animation }: DemoModalProps) => {
return (
<ModalWrapper animation={animation} contentContainerClassName="modalContent">
<h3 className="modalContent__title">
#3 Modal. Ok that's enough 😆 (Though you can keep stacking up as you wish.)
</h3>
<div className="modalContent__buttonContainer">
<button onClick={() => Reoverlay.hideModal()} type="button">
Hide
</button>
<button onClick={() => Reoverlay.hideAll()} type="button">
Hide all
</button>
</div>
</ModalWrapper>
)
}
const Modal2 = ({ animation }: DemoModalProps) => {
return (
<ModalWrapper animation={animation} contentContainerClassName="modalContent">
<h3 className="modalContent__title">
#2 Modal. It's getting dark here 🌗. Wanna see the third one?
</h3>
<div className="modalContent__buttonContainer">
<button onClick={() => Reoverlay.showModal(Modal3, { animation })} type="button">
Heck yeah! Show #3 Modal
</button>
<button onClick={() => Reoverlay.hideModal()} type="button">
Hide
</button>
<button onClick={() => Reoverlay.hideAll()} type="button">
Hide all
</button>
</div>
</ModalWrapper>
)
}
const Modal1 = ({ animation }: DemoModalProps) => {
return (
<ModalWrapper animation={animation} contentContainerClassName="modalContent">
<h3 className="modalContent__title">#1 Modal. So sweet! ❤️. Wanna see more?</h3>
<div className="modalContent__buttonContainer">
<button onClick={() => Reoverlay.showModal(Modal2, { animation })} type="button">
Yup! Show #2 Modal
</button>
<button onClick={() => Reoverlay.hideModal()} type="button">
Hide
</button>
</div>
</ModalWrapper>
)
}
const App = () => {
const showModal = (animation: ModalAnimation) => {
Reoverlay.showModal(Modal1, { animation })
}
return (
<main>
<div className="container">
<header className="header">
<img className="header__logo" src={logo} alt="Reoverlay" />
<h1 className="header__title">Reoverlay</h1>
<p className="header__description">The missing solution for managing modals in React.</p>
<div className="header__buttonContainer">
<a
className="header__githubButton"
href="https://github.com/hiradary/reoverlay"
target="_blank"
rel="noopener noreferrer"
>
<span className="header__githubButtonText">Visit GitHub</span>
<Icon name="github" />
</a>
<a
className="header__donationButton"
href="https://www.buymeacoffee.com/hiradary"
target="_blank"
rel="noopener noreferrer"
>
<span className="header__donationButtonText">Buy me a coffee</span>
<Icon name="donation" />
</a>
</div>
<Code code={installationCode} />
</header>
<section className="section">
<h3 className="section__title">Animation types</h3>
<p className="section__description">
There are quite a few preset animations. You can create your own custom animation too!
</p>
<div className="section__animationTypesContainer">
{animationTypes.map((type) => (
<button
className="section__animationTypeContainer"
key={type}
onClick={() => showModal(type)}
type="button"
>
<span className="section__animationType">{type}</span>
</button>
))}
</div>
</section>
<section className="section">
<h3 className="section__title">Usage, Props, etc.</h3>
<p className="section__description">
You can find more information on{' '}
<a
href="https://github.com/hiradary/reoverlay"
target="_blank"
rel="noopener noreferrer"
className="section__link"
>
github
</a>
.
</p>
</section>
<footer className="footer">
<img src={myPhoto} alt="Hirad Arshadi" className="footer__profilePhoto" />
<p className="footer__intentionText">
Made with{' '}
<span role="img" aria-label="Love">
❤️
</span>{' '}
for the react community
</p>
<a
href="https://twitter.com/hiradary"
target="_blank"
rel="noopener noreferrer"
className="footer__twitterContainer"
>
<Icon name="twitter" className="footer__twitterIcon" />
<span className="footer__twitterHandle">@hiradary</span>
</a>
</footer>
</div>
<ModalContainer />
</main>
)
}
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 (
<svg className={className} width="19" height="19" viewBox="0 0 19 19" fill="none">
<path
clipRule="evenodd"
d="M9.5 0.5C4.25204 0.5 0 4.62997 0 9.72726C0 13.8026 2.72175 17.2629 6.49721 18.4832C6.97379 18.5677 7.14638 18.2848 7.14638 18.0387C7.14638 17.8188 7.13767 17.239 7.1345 16.4701C4.49033 17.026 3.93379 15.2329 3.93379 15.2329C3.50075 14.1663 2.87929 13.8811 2.87929 13.8811C2.01638 13.3105 2.94421 13.3228 2.94421 13.3228C3.89658 13.3889 4.39771 14.2717 4.39771 14.2717C5.24479 15.6835 6.62071 15.2752 7.16221 15.0406C7.24929 14.4432 7.49629 14.0372 7.76546 13.8065C5.65646 13.575 3.439 12.7823 3.439 9.24668C3.439 8.23706 3.81029 7.4143 4.41592 6.76839C4.32013 6.53771 3.99237 5.59729 4.50854 4.32701C4.50854 4.32701 5.30654 4.08095 7.12183 5.2728C7.89702 5.0682 8.6967 4.96425 9.5 4.96369C10.3075 4.96676 11.1205 5.06826 11.8782 5.2728C13.6935 4.08095 14.4883 4.32701 14.4883 4.32701C15.0076 5.59729 14.683 6.53771 14.5833 6.76839C15.1929 7.4143 15.5578 8.23706 15.5578 9.24668C15.5578 12.7915 13.338 13.5689 11.2227 13.8003C11.5631 14.0825 11.8655 14.6469 11.8655 15.5066C11.8655 16.7408 11.8568 17.735 11.8568 18.0387C11.8568 18.2848 12.0262 18.5731 12.5091 18.4832C16.2814 17.2606 19 13.8026 19 9.72726C19 4.62997 14.748 0.5 9.5 0.5Z"
fill="white"
fillRule="evenodd"
/>
</svg>
)
case 'donation':
return (
<svg className={className} width="16" height="23" viewBox="0 0 16 23" fill="none">
<path
clipRule="evenodd"
d="M8.00672 5.12561L2.55713 5.09277L5.23327 22H5.81715H11.1694H11.7533L14.4295 5.09277L8.00672 5.12561Z"
fill="#1A273C"
fillRule="evenodd"
/>
<path
clipRule="evenodd"
d="M6.54918 5.03302L1.5 5L3.97951 22H4.52049H9.47951H10.0205L12.5 5L6.54918 5.03302Z"
fill="#435B81"
fillRule="evenodd"
/>
<path
clipRule="evenodd"
d="M8.18518 5.03302L3 5L5.5463 22H6.10185H9.89815H10.4537L13 5L8.18518 5.03302Z"
fill="#263B5D"
fillRule="evenodd"
/>
<path
clipRule="evenodd"
d="M1 5.09286H15.0619V3.53369H1V5.09286Z"
fill="white"
fillRule="evenodd"
/>
<path
clipRule="evenodd"
d="M1 5.09286H15.0619V3.53369H1V5.09286Z"
fillRule="evenodd"
stroke="black"
strokeWidth="0.729856"
/>
<path
clipRule="evenodd"
d="M12.3371 1H8.97979H6.98485H3.62751L2.60571 3.33875H6.98485H8.97979H13.3589L12.3371 1Z"
fill="white"
fillRule="evenodd"
/>
<path
clipRule="evenodd"
d="M12.3371 1H8.97979H6.98485H3.62751L2.60571 3.33875H6.98485H8.97979H13.3589L12.3371 1Z"
fillRule="evenodd"
stroke="#050505"
strokeWidth="0.729856"
/>
<path
clipRule="evenodd"
d="M8.00672 5.12561L1.58398 5.09277L4.26012 22H4.84401H11.1694H11.7533L14.4294 5.09277L8.00672 5.12561Z"
fillRule="evenodd"
stroke="black"
strokeWidth="0.729856"
/>
<path
clipRule="evenodd"
d="M14.6242 9.86768H8.21309H7.75192H1.34082L2.54002 16.5916L7.98251 16.533L13.425 16.5916L14.6242 9.86768Z"
fill="white"
fillRule="evenodd"
/>
<path
clipRule="evenodd"
d="M14.6242 9.86768H8.21309H7.75192H1.34082L2.54002 16.5916L7.98251 16.533L13.425 16.5916L14.6242 9.86768Z"
fillRule="evenodd"
stroke="black"
strokeWidth="0.729856"
/>
</svg>
)
case 'twitter':
return (
<svg className={className} width="24" height="24" viewBox="0 0 112.197 112.197">
<circle fill="#2578FF" cx="56.099" cy="56.098" r="56.098" />
<path
fill="#F1F2F2"
d="M90.461,40.316c-2.404,1.066-4.99,1.787-7.702,2.109c2.769-1.659,4.894-4.284,5.897-7.417
c-2.591,1.537-5.462,2.652-8.515,3.253c-2.446-2.605-5.931-4.233-9.79-4.233c-7.404,0-13.409,6.005-13.409,13.409
c0,1.051,0.119,2.074,0.349,3.056c-11.144-0.559-21.025-5.897-27.639-14.012c-1.154,1.98-1.816,4.285-1.816,6.742
c0,4.651,2.369,8.757,5.965,11.161c-2.197-0.069-4.266-0.672-6.073-1.679c-0.001,0.057-0.001,0.114-0.001,0.17
c0,6.497,4.624,11.916,10.757,13.147c-1.124,0.308-2.311,0.471-3.532,0.471c-0.866,0-1.705-0.083-2.523-0.239
c1.706,5.326,6.657,9.203,12.526,9.312c-4.59,3.597-10.371,5.74-16.655,5.74c-1.08,0-2.15-0.063-3.197-0.188
c5.931,3.806,12.981,6.025,20.553,6.025c24.664,0,38.152-20.432,38.152-38.153c0-0.581-0.013-1.16-0.039-1.734
C86.391,45.366,88.664,43.005,90.461,40.316L90.461,40.316z"
/>
</svg>
)
}
}
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(
<StrictMode>
<App />
</StrictMode>
)
================================================
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 <hiradwork@gmail.com>",
"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<ActiveModal[]>([])
useEffect(() => {
const unsubscribe = eventManager.on<ActiveModal[]>(EVENT.CHANGE_MODAL, setModals)
return unsubscribe
}, [])
return (
<div className="reOverlay">
{modals.map(({ modalKey, component, props }) => {
if (isValidElement(component)) {
return <Fragment key={`id-${modalKey}`}>{cloneElement(component, props)}</Fragment>
}
const Component = component
return (
<Fragment key={`id-${modalKey}`}>
<Component {...props} />
</Fragment>
)
})}
</div>
)
}
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<HTMLDivElement>(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<HTMLDivElement>) => {
if (event.target === wrapperElement.current) {
onClose(event)
}
}
return (
<div
aria-describedby={ariaDescribedBy}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-modal="true"
className={`reOverlay__modalWrapper -ro-${animation} ${wrapperClassName}`.trim()}
onClick={handleClickOutside}
ref={wrapperElement}
role={role}
>
<div className={`reOverlay__modalContainer ${contentContainerClassName}`.trim()}>
{children}
</div>
</div>
)
}
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<P extends ModalProps = ModalProps> = Omit<ActiveModal<P>, 'modalKey'>
let modalId = 0
const createModalKey = () => {
modalId += 1
return `reoverlay-${modalId}`
}
const Reoverlay = {
modals: new Map<string, ModalRenderable>(),
snapshots: new Map<string, ModalSnapshot<any>>(),
config(configData: ModalConfigItem[] = []) {
validate(VALIDATE.CONFIG, configData)
configData.forEach((item) => {
this.modals.set(item.name, item.component)
})
},
showModal<P extends ModalProps = ModalProps>(
modal: ModalRenderable<P> | 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<P>,
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<ActiveModal<any>> & { 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<string, unknown>
export type ModalComponent<P = any> = React.ElementType<P>
export type ModalElement<P = any> = React.ReactElement<P>
export type ModalRenderable<P = any> = ModalComponent<P> | ModalElement<P>
export type ModalConfigItem<P = any> = {
name: string
component: ModalRenderable<P>
}
export type ModalAnimation =
| 'fade'
| 'zoom'
| 'flip'
| 'door'
| 'rotate'
| 'slideUp'
| 'slideDown'
| 'slideLeft'
| 'slideRight'
export type ModalCloseEvent =
| React.MouseEvent<HTMLDivElement>
| KeyboardEvent
| React.KeyboardEvent<HTMLDivElement>
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<P = ModalProps> = {
component: ModalRenderable<P>
modalKey: string
props: P
}
================================================
FILE: src/utils/eventManager.ts
================================================
type Listener<TPayload> = (payload: TPayload) => void
const eventManager = (() => {
const subscribers = new Map<string, Set<Listener<unknown>>>()
const on = <TPayload>(eventName: string, callback: Listener<TPayload>) => {
if (!subscribers.has(eventName)) {
subscribers.set(eventName, new Set())
}
const listeners = subscribers.get(eventName)
listeners?.add(callback as Listener<unknown>)
return () => off(eventName, callback)
}
const off = <TPayload>(eventName?: string, callback?: Listener<TPayload>) => {
if (!eventName) {
subscribers.clear()
return
}
if (!callback) {
subscribers.delete(eventName)
return
}
const listeners = subscribers.get(eventName)
listeners?.delete(callback as Listener<unknown>)
if (listeners?.size === 0) {
subscribers.delete(eventName)
}
}
const emit = <TPayload>(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 = <TValue>(array: TValue[]) => array[array.length - 1]
export const isArrayUnique = <TValue>(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 }) => <div>{label}</div>
afterEach(() => {
act(() => {
Reoverlay.hideAll()
})
cleanup()
})
describe('ModalContainer', () => {
it('subscribes once and cleans up the exact change listener', () => {
const { unmount } = render(<ModalContainer />)
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(
<ModalWrapper
aria-label="Confirm action"
animation="slideUp"
contentContainerClassName="custom-content"
wrapperClassName="custom-wrapper"
>
Content
</ModalWrapper>
)
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(
<ModalWrapper aria-label="Close from backdrop" onClose={onClose}>
<button type="button">Inside</button>
</ModalWrapper>
)
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(
<ModalWrapper aria-label="Close from keyboard" onClose={onClose}>
Content
</ModalWrapper>
)
fireEvent.keyDown(document, { key: 'Escape' })
expect(onClose).toHaveBeenCalledTimes(1)
})
it('can disable Escape close behavior', () => {
const onClose = vi.fn()
render(
<ModalWrapper aria-label="Escape disabled" closeOnEscape={false} onClose={onClose}>
Content
</ModalWrapper>
)
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 }) => <div>{label}</div>
afterEach(() => {
act(() => {
Reoverlay.hideAll()
})
cleanup()
vi.restoreAllMocks()
})
describe('Reoverlay', () => {
it('renders a direct modal component with props', () => {
render(<ModalContainer />)
act(() => {
Reoverlay.showModal(TestModal, { label: 'Direct modal' })
})
expect(screen.getByText('Direct modal')).toBeInTheDocument()
})
it('renders a configured modal by name', () => {
render(<ModalContainer />)
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(<ModalContainer />)
act(() => {
Reoverlay.showModal(<TestModal label="Element modal" />)
})
expect(screen.getByText('Element modal')).toBeInTheDocument()
})
it('hides the last modal by default', () => {
render(<ModalContainer />)
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(<ModalContainer />)
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(<ModalContainer />)
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'
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
SYMBOL INDEX (24 symbols across 8 files)
FILE: demo/src/App.tsx
type DemoModalProps (line 26) | type DemoModalProps = {
FILE: demo/src/Icon.tsx
type IconProps (line 1) | type IconProps = {
FILE: src/Reoverlay.ts
type ModalSnapshot (line 5) | type ModalSnapshot<P extends ModalProps = ModalProps> = Omit<ActiveModal...
method config (line 18) | config(configData: ModalConfigItem[] = []) {
method showModal (line 26) | showModal<P extends ModalProps = ModalProps>(
method getSnapshotsArray (line 59) | getSnapshotsArray() {
method hideModal (line 66) | hideModal(modal: string | null = null) {
method hideAll (line 95) | hideAll() {
method applyModal (line 99) | applyModal({
FILE: src/constants/index.ts
constant VALIDATE (line 1) | const VALIDATE = {
constant EVENT (line 7) | const EVENT = {
FILE: src/types.ts
type ModalProps (line 3) | type ModalProps = Record<string, unknown>
type ModalComponent (line 5) | type ModalComponent<P = any> = React.ElementType<P>
type ModalElement (line 7) | type ModalElement<P = any> = React.ReactElement<P>
type ModalRenderable (line 9) | type ModalRenderable<P = any> = ModalComponent<P> | ModalElement<P>
type ModalConfigItem (line 11) | type ModalConfigItem<P = any> = {
type ModalAnimation (line 16) | type ModalAnimation =
type ModalCloseEvent (line 27) | type ModalCloseEvent =
type ModalWrapperProps (line 32) | type ModalWrapperProps = {
type ActiveModal (line 45) | type ActiveModal<P = ModalProps> = {
FILE: src/utils/eventManager.ts
type Listener (line 1) | type Listener<TPayload> = (payload: TPayload) => void
FILE: src/utils/validator.ts
type ValidationType (line 7) | type ValidationType = (typeof VALIDATE)[keyof typeof VALIDATE]
type ShowModalValidationResult (line 9) | type ShowModalValidationResult = 'component' | 'string'
FILE: tsup.config.ts
method outExtension (line 21) | outExtension({ format }) {
Condensed preview — 37 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (63K chars).
[
{
"path": ".github/workflows/ci.yml",
"chars": 722,
"preview": "name: CI\n\non:\n pull_request:\n push:\n branches:\n - master\n - main\n\njobs:\n checks:\n name: Checks\n ru"
},
{
"path": ".gitignore",
"chars": 358,
"preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/**/"
},
{
"path": ".prettierrc",
"chars": 107,
"preview": "{\n \"trailingComma\": \"es5\",\n \"tabWidth\": 2,\n \"semi\": false,\n \"singleQuote\": true,\n \"printWidth\": 100\n}\n"
},
{
"path": ".vscode/settings.json",
"chars": 37,
"preview": "{\n \"git.ignoreLimitWarning\": true\n}\n"
},
{
"path": "LICENSE",
"chars": 1069,
"preview": "MIT License\n\nCopyright (c) 2020 Hirad Arshadi\n\nPermission is hereby granted, free of charge, to any person obtaining a c"
},
{
"path": "README.md",
"chars": 6250,
"preview": "# Reoverlay\n\nA tiny, typed modal manager for React. Reoverlay gives you one top-level\n`ModalContainer` and a small imper"
},
{
"path": "demo/index.html",
"chars": 397,
"preview": "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-w"
},
{
"path": "demo/package.json",
"chars": 508,
"preview": "{\n \"name\": \"reoverlay-demo\",\n \"private\": true,\n \"version\": \"0.0.0\",\n \"type\": \"module\",\n \"scripts\": {\n \"build\": \""
},
{
"path": "demo/src/App.tsx",
"chars": 5764,
"preview": "import { ModalContainer, ModalWrapper, Reoverlay, type ModalAnimation } from 'reoverlay'\nimport 'reoverlay/ModalWrapper."
},
{
"path": "demo/src/Icon.tsx",
"chars": 5205,
"preview": "type IconProps = {\n className?: string\n name: 'donation' | 'github' | 'twitter'\n}\n\nconst Icon = ({ className, name }: "
},
{
"path": "demo/src/main.tsx",
"chars": 241,
"preview": "import { StrictMode } from 'react'\nimport { createRoot } from 'react-dom/client'\n\nimport App from './App'\nimport './styl"
},
{
"path": "demo/src/styles.css",
"chars": 6486,
"preview": "@font-face {\n font-family: 'ProductSans';\n font-weight: 400;\n src:\n local('Product Sans'),\n local('ProductSans-"
},
{
"path": "demo/tsconfig.json",
"chars": 319,
"preview": "{\n \"extends\": \"../tsconfig.json\",\n \"compilerOptions\": {\n \"allowImportingTsExtensions\": true,\n \"noEmit\": true,\n "
},
{
"path": "demo/vite.config.ts",
"chars": 574,
"preview": "import path from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\nimport react from '@vitejs/plugin-react'\nimport {"
},
{
"path": "eslint.config.mjs",
"chars": 532,
"preview": "import js from '@eslint/js'\nimport tseslint from 'typescript-eslint'\n\nexport default tseslint.config(\n {\n ignores: ["
},
{
"path": "package.json",
"chars": 2286,
"preview": "{\n \"name\": \"reoverlay\",\n \"version\": \"1.1.0\",\n \"description\": \"A tiny, typed modal manager for React.\",\n \"license\": \""
},
{
"path": "pnpm-workspace.yaml",
"chars": 19,
"preview": "packages:\n - demo\n"
},
{
"path": "src/ModalContainer.tsx",
"chars": 903,
"preview": "import { Fragment, cloneElement, isValidElement, useEffect, useState } from 'react'\n\nimport { EVENT } from './constants'"
},
{
"path": "src/ModalWrapper.css",
"chars": 7069,
"preview": ".reOverlay .reOverlay__modalWrapper {\n width: 100%;\n height: 100vh;\n height: 100dvh;\n position: fixed;\n top: 0;\n l"
},
{
"path": "src/ModalWrapper.tsx",
"chars": 1579,
"preview": "import { useEffect, useRef } from 'react'\nimport type React from 'react'\n\nimport Reoverlay from './Reoverlay'\nimport typ"
},
{
"path": "src/Reoverlay.ts",
"chars": 3009,
"preview": "import { EVENT, VALIDATE } from './constants'\nimport type { ActiveModal, ModalConfigItem, ModalProps, ModalRenderable } "
},
{
"path": "src/constants/index.ts",
"chars": 260,
"preview": "export const VALIDATE = {\n CONFIG: 'config',\n HIDE_MODAL: 'hide_modal',\n SHOW_MODAL: 'show_modal',\n} as const\n\nexport"
},
{
"path": "src/index.ts",
"chars": 359,
"preview": "export { default as ModalContainer } from './ModalContainer'\nexport { default as ModalWrapper } from './ModalWrapper'\nex"
},
{
"path": "src/types.ts",
"chars": 1100,
"preview": "import type React from 'react'\n\nexport type ModalProps = Record<string, unknown>\n\nexport type ModalComponent<P = any> = "
},
{
"path": "src/utils/eventManager.ts",
"chars": 1216,
"preview": "type Listener<TPayload> = (payload: TPayload) => void\n\nconst eventManager = (() => {\n const subscribers = new Map<strin"
},
{
"path": "src/utils/index.ts",
"chars": 109,
"preview": "export { default as eventManager } from './eventManager'\nexport * from './utils'\nexport * from './validator'\n"
},
{
"path": "src/utils/utils.ts",
"chars": 419,
"preview": "export const getLastElement = <TValue>(array: TValue[]) => array[array.length - 1]\n\nexport const isArrayUnique = <TValue"
},
{
"path": "src/utils/validator.ts",
"chars": 1950,
"preview": "import { isValidElement } from 'react'\n\nimport { VALIDATE } from '../constants'\nimport type { ModalConfigItem, ModalRend"
},
{
"path": "tests/ModalContainer.test.tsx",
"chars": 958,
"preview": "import { act, cleanup, render, screen } from '@testing-library/react'\nimport { afterEach, describe, expect, it } from 'v"
},
{
"path": "tests/ModalWrapper.test.tsx",
"chars": 1885,
"preview": "import { fireEvent, render, screen } from '@testing-library/react'\nimport { describe, expect, it, vi } from 'vitest'\n\nim"
},
{
"path": "tests/Reoverlay.test.tsx",
"chars": 3021,
"preview": "import { cleanup, render, screen, act } from '@testing-library/react'\nimport { afterEach, describe, expect, it, vi } fro"
},
{
"path": "tests/package-smoke.test.ts",
"chars": 1232,
"preview": "import { createRequire } from 'node:module'\nimport { execFileSync } from 'node:child_process'\nimport { existsSync } from"
},
{
"path": "tsconfig.build.json",
"chars": 288,
"preview": "{\n \"extends\": \"./tsconfig.json\",\n \"compilerOptions\": {\n \"declaration\": true,\n \"declarationMap\": true,\n \"emitD"
},
{
"path": "tsconfig.json",
"chars": 692,
"preview": "{\n \"compilerOptions\": {\n \"allowSyntheticDefaultImports\": true,\n \"declaration\": true,\n \"esModuleInterop\": true,"
},
{
"path": "tsup.config.ts",
"chars": 726,
"preview": "import { copyFile, mkdir } from 'node:fs/promises'\n\nimport { defineConfig } from 'tsup'\n\nconst copyStyles = async () => "
},
{
"path": "vitest.config.ts",
"chars": 178,
"preview": "import { defineConfig } from 'vitest/config'\n\nexport default defineConfig({\n test: {\n environment: 'jsdom',\n glob"
},
{
"path": "vitest.setup.ts",
"chars": 42,
"preview": "import '@testing-library/jest-dom/vitest'\n"
}
]
About this extraction
This page contains the full source code of the hiradary/reoverlay GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 37 files (56.5 KB), approximately 17.6k tokens, and a symbol index with 24 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.