@jielga/react-popup-window 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/dist/copyStyles.d.ts +18 -0
- package/dist/copyStyles.d.ts.map +1 -0
- package/dist/index.cjs +4 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +174 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +73 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/usePopupWindow.d.ts +23 -0
- package/dist/usePopupWindow.d.ts.map +1 -0
- package/package.json +100 -0
- package/skills/getting-started/SKILL.md +211 -0
- package/skills/popup-content/SKILL.md +227 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 jielga
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# @jielga/react-popup-window
|
|
2
|
+
|
|
3
|
+
[](https://github.com/jielga/react-popup-window/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@jielga/react-popup-window)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
|
|
7
|
+
React hook for rendering part of a component tree in a separate browser
|
|
8
|
+
window. Content is rendered through a portal into the popup document, so it
|
|
9
|
+
remains part of the calling tree: state, context, and event handlers work
|
|
10
|
+
across windows without bridging.
|
|
11
|
+
|
|
12
|
+
[Documentation and live examples](https://jielga.github.io/react-popup-window/)
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- Portal-based rendering — popup content keeps access to all ancestor
|
|
17
|
+
context (state managers, data fetching, theming, routing)
|
|
18
|
+
- Stylesheet synchronization — `<style>` and `<link>` elements, root
|
|
19
|
+
`class`/`data-*` attributes, and `adoptedStyleSheets` are mirrored into
|
|
20
|
+
the popup and kept current while it is open
|
|
21
|
+
- Lifecycle management — detects the user closing the window, closes the
|
|
22
|
+
popup on owner unmount and opener unload, reports blocked popups
|
|
23
|
+
- No dependencies beyond `react` and `react-dom`
|
|
24
|
+
- TypeScript, ESM and CJS builds, SSR-safe
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
npm install @jielga/react-popup-window
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Requires React 19.2 or later.
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
import { usePopupWindow } from '@jielga/react-popup-window'
|
|
38
|
+
|
|
39
|
+
function Dashboard() {
|
|
40
|
+
const { open, close, isOpen, Popup } = usePopupWindow({
|
|
41
|
+
title: 'Detached panel',
|
|
42
|
+
features: { width: 640, height: 480 },
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<>
|
|
47
|
+
<button onClick={open}>Open in new window</button>
|
|
48
|
+
<Popup>
|
|
49
|
+
<MyPanel />
|
|
50
|
+
</Popup>
|
|
51
|
+
</>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`Popup` renders its children into the popup window while open and nothing
|
|
57
|
+
otherwise. It has a stable identity and can be destructured from the hook
|
|
58
|
+
result.
|
|
59
|
+
|
|
60
|
+
### Detaching a section
|
|
61
|
+
|
|
62
|
+
To hide a section in the main window while it is popped out, render it in
|
|
63
|
+
one of two places depending on `isOpen`:
|
|
64
|
+
|
|
65
|
+
```tsx
|
|
66
|
+
const { open, close, focus, isOpen, Popup } = usePopupWindow({ title: 'People' })
|
|
67
|
+
|
|
68
|
+
const table = <DataTable />
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<>
|
|
72
|
+
{isOpen ? (
|
|
73
|
+
<div>
|
|
74
|
+
<button onClick={focus}>Focus window</button>
|
|
75
|
+
<button onClick={close}>Bring back</button>
|
|
76
|
+
</div>
|
|
77
|
+
) : (
|
|
78
|
+
<>
|
|
79
|
+
<button onClick={open}>Open in new window</button>
|
|
80
|
+
{table}
|
|
81
|
+
</>
|
|
82
|
+
)}
|
|
83
|
+
<Popup>{table}</Popup>
|
|
84
|
+
</>
|
|
85
|
+
)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`isOpen` also updates when the user closes the window directly, so the
|
|
89
|
+
inline branch is restored in every case.
|
|
90
|
+
|
|
91
|
+
## How it works
|
|
92
|
+
|
|
93
|
+
The hook opens a same-origin `about:blank` window and creates a container
|
|
94
|
+
element in its body. The `Popup` component renders its children with
|
|
95
|
+
`createPortal` into that container. A portal changes where DOM output is
|
|
96
|
+
placed, not where the components sit in the tree, so popup content
|
|
97
|
+
participates in the calling tree's state, context, and event system. React
|
|
98
|
+
supports cross-document portals: it attaches its event delegation to the
|
|
99
|
+
portal container when the container belongs to another document.
|
|
100
|
+
|
|
101
|
+
Physically moving DOM nodes into another document is not a viable
|
|
102
|
+
alternative: React delegates events on the root container in the main
|
|
103
|
+
document, and a node moved elsewhere stops receiving synthetic events.
|
|
104
|
+
|
|
105
|
+
The popup document runs no JavaScript of its own. All rendering and event
|
|
106
|
+
handling execute in the opener window.
|
|
107
|
+
|
|
108
|
+
## API
|
|
109
|
+
|
|
110
|
+
### `usePopupWindow(options?)`
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
interface UsePopupWindowOptions {
|
|
114
|
+
/** Popup document title. Defaults to the opener document's title. */
|
|
115
|
+
title?: string
|
|
116
|
+
/** window.open target name. The same name reuses the window. Default: '_blank'. */
|
|
117
|
+
name?: string
|
|
118
|
+
/** window.open features, merged over { popup: true, width: 640, height: 480 }. */
|
|
119
|
+
features?: PopupWindowFeatures
|
|
120
|
+
/** Center the popup over the opener when no left/top feature is given. Default: true. */
|
|
121
|
+
center?: boolean
|
|
122
|
+
/** Mirror and synchronize stylesheets into the popup. Default: true. */
|
|
123
|
+
copyStyles?: boolean
|
|
124
|
+
/** Called after the popup window is opened and prepared. */
|
|
125
|
+
onOpen?: (popupWindow: Window) => void
|
|
126
|
+
/** Called when the popup closes: close(), user close, or opener unload. */
|
|
127
|
+
onClose?: () => void
|
|
128
|
+
/** Called when window.open returns null (popup blocked). */
|
|
129
|
+
onBlocked?: () => void
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
|
|
135
|
+
| Member | Type | Description |
|
|
136
|
+
| ------------- | -------------------------- | ------------------------------------------------------------------------------------------------- |
|
|
137
|
+
| `Popup` | `FC<{ children? }>` | Portal component. Renders children into the popup while open. |
|
|
138
|
+
| `open` | `() => Window \| null` | Opens the popup, or focuses it if already open. Returns `null` when blocked. Requires a user gesture. |
|
|
139
|
+
| `close` | `() => void` | Closes the popup. |
|
|
140
|
+
| `toggle` | `() => void` | Opens if closed, closes if open. |
|
|
141
|
+
| `focus` | `() => void` | Focuses the popup window. |
|
|
142
|
+
| `isOpen` | `boolean` | Whether the popup is open. |
|
|
143
|
+
| `isBlocked` | `boolean` | Whether the last `open()` call was blocked. |
|
|
144
|
+
| `popupWindow` | `Window \| null` | The popup `Window` while open. |
|
|
145
|
+
|
|
146
|
+
### `copyStyles(source, target, watch?)`
|
|
147
|
+
|
|
148
|
+
The style synchronization used by the hook, exported for windows managed
|
|
149
|
+
outside of it. Copies stylesheets from `source` to `target` and, when
|
|
150
|
+
`watch` is true (default), observes the source document for changes.
|
|
151
|
+
Returns a function that stops observing.
|
|
152
|
+
|
|
153
|
+
## Style synchronization
|
|
154
|
+
|
|
155
|
+
While the popup is open, the following are mirrored from the opener
|
|
156
|
+
document and kept current:
|
|
157
|
+
|
|
158
|
+
- `<style>` and `<link rel="stylesheet">` elements. `<style>` contents are
|
|
159
|
+
serialized from the CSSOM, so rules injected with `insertRule` are
|
|
160
|
+
included.
|
|
161
|
+
- Additions, removals, and text edits of style nodes in `<head>`. This
|
|
162
|
+
covers Vite HMR, lazily loaded chunk CSS, and CSS-in-JS libraries.
|
|
163
|
+
- `class`, `style`, and `data-*` attributes on `<html>` and `<body>`.
|
|
164
|
+
Theme systems keyed on root attributes propagate to the popup.
|
|
165
|
+
- `document.adoptedStyleSheets`.
|
|
166
|
+
|
|
167
|
+
Set `copyStyles: false` to disable.
|
|
168
|
+
|
|
169
|
+
## Communication
|
|
170
|
+
|
|
171
|
+
Popup content rendered through `Popup` is part of the calling component
|
|
172
|
+
tree and executes in the opener's JavaScript realm. Props, state, and
|
|
173
|
+
context are the communication mechanism; no message channel is required or
|
|
174
|
+
provided.
|
|
175
|
+
|
|
176
|
+
`postMessage` remains relevant only for scripts hosted in the popup
|
|
177
|
+
document itself (for example, an injected non-React widget). Such scripts
|
|
178
|
+
run in the popup's realm and can post to `window.opener`; the exposed
|
|
179
|
+
`popupWindow` handle can be used from the opener side. Note that calling
|
|
180
|
+
`opener.postMessage` from a portal event handler posts from the opener's
|
|
181
|
+
own realm — the browser reports the main window, not the popup, as
|
|
182
|
+
`event.source`.
|
|
183
|
+
|
|
184
|
+
## Portal-based UI libraries
|
|
185
|
+
|
|
186
|
+
Component libraries typically mount overlays — menus, popovers, modals,
|
|
187
|
+
tooltips — into `document.body`, which is the main window's body even for
|
|
188
|
+
components rendered inside the popup. Provide a portal target inside the
|
|
189
|
+
popup document instead:
|
|
190
|
+
|
|
191
|
+
- Mantine: portal defaults can be set through the theme. See
|
|
192
|
+
[`SameWindowPortals`](docs/src/examples/SameWindowPortals.tsx) for a
|
|
193
|
+
wrapper that resolves its own `ownerDocument` and supplies that
|
|
194
|
+
document's body as the default `Portal` target.
|
|
195
|
+
- Radix, MUI, and similar: use the per-component portal `container` prop
|
|
196
|
+
with `popupWindow.document.body`.
|
|
197
|
+
|
|
198
|
+
## Limitations
|
|
199
|
+
|
|
200
|
+
- Browsers do not allow hiding the address bar entirely. `popup: true`
|
|
201
|
+
(the default) requests the minimal window chrome the platform provides.
|
|
202
|
+
- `open()` must be called from a user gesture; otherwise the browser's
|
|
203
|
+
popup blocker intervenes and `open()` returns `null`.
|
|
204
|
+
- Popup content unmounts and remounts when it moves between windows. State
|
|
205
|
+
that should survive detaching belongs in the component that owns the
|
|
206
|
+
hook, or in an external store.
|
|
207
|
+
- The popup closes when the owning component unmounts and when the opener
|
|
208
|
+
window unloads. The popup document cannot outlive the opener.
|
|
209
|
+
|
|
210
|
+
## Agent skills
|
|
211
|
+
|
|
212
|
+
The package ships [Agent Skills](https://agentskills.io) for AI coding
|
|
213
|
+
agents, managed with [`@tanstack/intent`](https://www.npmjs.com/package/@tanstack/intent):
|
|
214
|
+
|
|
215
|
+
```sh
|
|
216
|
+
npx @tanstack/intent@latest list
|
|
217
|
+
npx @tanstack/intent@latest load @jielga/react-popup-window#getting-started
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
| Skill | Contents |
|
|
221
|
+
| ----------------- | -------------------------------------------------------------------- |
|
|
222
|
+
| `getting-started` | Hook API, options, lifecycle, common mistakes |
|
|
223
|
+
| `popup-content` | Style synchronization, bounded-height layouts, portal-based overlays |
|
|
224
|
+
|
|
225
|
+
## Development
|
|
226
|
+
|
|
227
|
+
| Path | Contents |
|
|
228
|
+
| ----------- | --------------------------------------------------------------- |
|
|
229
|
+
| `src/` | Library source. Vite library mode; ESM, CJS, and declarations. |
|
|
230
|
+
| `docs/` | Documentation site with live examples, deployed to GitHub Pages. |
|
|
231
|
+
| `e2e/` | Playwright tests that exercise real popup windows in Chromium. |
|
|
232
|
+
| `skills/` | Agent skills shipped with the package. |
|
|
233
|
+
|
|
234
|
+
```sh
|
|
235
|
+
npm install
|
|
236
|
+
npm run dev # documentation site with the library aliased to source
|
|
237
|
+
npm test # unit tests (Vitest, jsdom)
|
|
238
|
+
npm run e2e # end-to-end tests (Playwright)
|
|
239
|
+
npm run build # build the library into dist/
|
|
240
|
+
npm run skills:validate # validate agent skills
|
|
241
|
+
npm run changeset # describe a change for the changelog and the next release
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Releases are published to npm by Changesets when the **chore: version packages**
|
|
245
|
+
pull request is merged — see [RELEASING.md](./RELEASING.md).
|
|
246
|
+
|
|
247
|
+
## License
|
|
248
|
+
|
|
249
|
+
[MIT](./LICENSE)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copy all stylesheets from `source` into `target` and (optionally) keep them
|
|
3
|
+
* in sync while the popup is open:
|
|
4
|
+
*
|
|
5
|
+
* - `<link rel="stylesheet">` and `<style>` elements are mirrored into the
|
|
6
|
+
* target `<head>`. `<style>` contents are serialized from the CSSOM when
|
|
7
|
+
* possible, so rules injected via `insertRule` (CSS-in-JS "speedy" mode)
|
|
8
|
+
* are included.
|
|
9
|
+
* - Additions/removals/edits of style nodes in the source `<head>` are
|
|
10
|
+
* observed (covers Vite HMR, lazily loaded chunk CSS, styled-components).
|
|
11
|
+
* - `class`/`style`/`data-*` attributes on `<html>` and `<body>` are mirrored
|
|
12
|
+
* and kept in sync, so theme switching (e.g. a `dark` class) propagates.
|
|
13
|
+
* - `document.adoptedStyleSheets` are re-constructed in the target document.
|
|
14
|
+
*
|
|
15
|
+
* Returns a function that stops observing.
|
|
16
|
+
*/
|
|
17
|
+
export declare function copyStyles(source: Document, target: Document, watch?: boolean): () => void;
|
|
18
|
+
//# sourceMappingURL=copyStyles.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"copyStyles.d.ts","sourceRoot":"","sources":["../src/copyStyles.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,UAAO,GAAG,MAAM,IAAI,CAuJvF"}
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});let e=require("react"),t=require("react-dom");function n(e,t,n=!0){let r=new Map,i=e=>{if(e.nodeType!==Node.ELEMENT_NODE)return!1;let t=e;return t.tagName===`STYLE`||t.tagName===`LINK`&&(t.getAttribute(`rel`)??``).toLowerCase().includes(`stylesheet`)},a=e=>{let t=e.sheet;if(t)try{return Array.from(t.cssRules).map(e=>e.cssText).join(`
|
|
2
|
+
`)}catch{}return e.textContent??``},o=e=>{if(r.has(e))return;let n;if(e.tagName===`LINK`){let r=t.createElement(`link`);r.rel=`stylesheet`,r.href=e.href;let i=e.getAttribute(`media`);i&&(r.media=i),n=r}else{let r=t.createElement(`style`);r.textContent=a(e);let i=e.getAttribute(`media`);i&&r.setAttribute(`media`,i),n=r}t.head.appendChild(n),r.set(e,n)},s=e=>{let t=r.get(e);t&&(t.textContent=a(e))},c=e=>{for(let[t,n]of r)(e===t||e.nodeType===Node.ELEMENT_NODE&&e.contains(t))&&(n.remove(),r.delete(t))},l=[`class`,`style`],u=()=>{let n=[[e.documentElement,t.documentElement],[e.body,t.body]];for(let[e,t]of n)if(!(!e||!t)){for(let n of e.attributes)(l.includes(n.name)||n.name.startsWith(`data-`))&&t.setAttribute(n.name,n.value);for(let n of Array.from(t.attributes))(l.includes(n.name)||n.name.startsWith(`data-`))&&!e.hasAttribute(n.name)&&t.removeAttribute(n.name)}},d=()=>{let n=t.defaultView;if(!(!n?.CSSStyleSheet||!(`adoptedStyleSheets`in e)))try{t.adoptedStyleSheets=e.adoptedStyleSheets.map(e=>{let t=new n.CSSStyleSheet;return t.replaceSync(Array.from(e.cssRules).map(e=>e.cssText).join(`
|
|
3
|
+
`)),t})}catch{}};for(let t of e.querySelectorAll(`style, link[rel~="stylesheet" i]`))o(t);if(u(),d(),!n||typeof MutationObserver>`u`)return()=>{};let f=new MutationObserver(e=>{for(let t of e){if(t.type===`characterData`){let e=t.target.parentElement;e?.tagName===`STYLE`&&s(e);continue}i(t.target)&&t.target.tagName===`STYLE`&&s(t.target);for(let e of t.addedNodes)if(i(e))o(e);else if(e.nodeType===Node.ELEMENT_NODE)for(let t of e.querySelectorAll(`style, link[rel~="stylesheet" i]`))o(t);for(let e of t.removedNodes)c(e)}});f.observe(e.head,{childList:!0,subtree:!0,characterData:!0});let p=new MutationObserver(()=>u());return p.observe(e.documentElement,{attributes:!0}),e.body&&p.observe(e.body,{attributes:!0}),()=>{f.disconnect(),p.disconnect()}}var r={popupWindow:null,container:null,blocked:!1},i=class{state=r;listeners=new Set;subscribe=e=>(this.listeners.add(e),()=>this.listeners.delete(e));getSnapshot=()=>this.state;getServerSnapshot=()=>r;setState(e){this.state={...this.state,...e};for(let e of this.listeners)e()}},a={popup:!0,width:640,height:480};function o(e){let t={...a,...e.features};return e.center!==!1&&t.left===void 0&&t.top===void 0&&typeof t.width==`number`&&typeof t.height==`number`&&(t.left=Math.max(0,Math.round(window.screenX+(window.outerWidth-t.width)/2)),t.top=Math.max(0,Math.round(window.screenY+(window.outerHeight-t.height)/2))),Object.entries(t).filter(([,e])=>e!==void 0&&e!==!1).map(([e,t])=>t===!0?`${e}=yes`:`${e}=${t}`).join(`,`)}function s(n){function r({children:r}){let{container:i}=(0,e.useSyncExternalStore)(n.subscribe,n.getSnapshot,n.getServerSnapshot);return i?(0,t.createPortal)(r,i):null}return r.displayName=`PopupWindow.Popup`,r}function c(t={}){let[r]=(0,e.useState)(()=>new i),[a]=(0,e.useState)(()=>s(r)),c=(0,e.useRef)(t);c.current=t;let l=(0,e.useRef)(null),u=(0,e.useSyncExternalStore)(r.subscribe,r.getSnapshot,r.getServerSnapshot),d=(0,e.useCallback)(e=>{let{popupWindow:t}=r.getSnapshot();l.current?.(),l.current=null,t&&(!e&&!t.closed&&t.close(),r.setState({popupWindow:null,container:null}),c.current.onClose?.())},[r]),f=(0,e.useCallback)(()=>{if(typeof window>`u`)return null;let e=r.getSnapshot();if(e.popupWindow&&!e.popupWindow.closed)return e.popupWindow.focus(),e.popupWindow;let t=c.current,i=window.open(`about:blank`,t.name??`_blank`,o(t));if(!i)return r.setState({blocked:!0}),t.onBlocked?.(),null;let a=i.document;a.title=t.title??document.title;let s;t.copyStyles!==!1&&(s=n(document,a));let u=a.createElement(`div`);u.setAttribute(`data-popup-window-root`,``),a.body.appendChild(u);let f=()=>d(!0),p=()=>{setTimeout(()=>{i.closed&&f()},0)};i.addEventListener(`pagehide`,p);let m=window.setInterval(()=>{i.closed&&f()},250),h=()=>i.close();return window.addEventListener(`pagehide`,h),l.current=()=>{window.clearInterval(m),window.removeEventListener(`pagehide`,h),i.removeEventListener(`pagehide`,p),s?.()},r.setState({popupWindow:i,container:u,blocked:!1}),t.onOpen?.(i),i},[r,d]),p=(0,e.useCallback)(()=>d(!1),[d]),m=(0,e.useCallback)(()=>{r.getSnapshot().popupWindow?d(!1):f()},[r,d,f]),h=(0,e.useCallback)(()=>{let{popupWindow:e}=r.getSnapshot();e&&!e.closed&&e.focus()},[r]);return(0,e.useEffect)(()=>()=>d(!1),[d]),{open:f,close:p,toggle:m,focus:h,isOpen:u.popupWindow!==null,isBlocked:u.blocked,popupWindow:u.popupWindow,Popup:a}}exports.copyStyles=n,exports.usePopupWindow=c;
|
|
4
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.cjs","names":[],"sources":["../src/copyStyles.ts","../src/usePopupWindow.ts"],"sourcesContent":["/**\r\n * Copy all stylesheets from `source` into `target` and (optionally) keep them\r\n * in sync while the popup is open:\r\n *\r\n * - `<link rel=\"stylesheet\">` and `<style>` elements are mirrored into the\r\n * target `<head>`. `<style>` contents are serialized from the CSSOM when\r\n * possible, so rules injected via `insertRule` (CSS-in-JS \"speedy\" mode)\r\n * are included.\r\n * - Additions/removals/edits of style nodes in the source `<head>` are\r\n * observed (covers Vite HMR, lazily loaded chunk CSS, styled-components).\r\n * - `class`/`style`/`data-*` attributes on `<html>` and `<body>` are mirrored\r\n * and kept in sync, so theme switching (e.g. a `dark` class) propagates.\r\n * - `document.adoptedStyleSheets` are re-constructed in the target document.\r\n *\r\n * Returns a function that stops observing.\r\n */\r\nexport function copyStyles(source: Document, target: Document, watch = true): () => void {\r\n const mirrors = new Map<Element, Element>()\r\n\r\n const isStyleNode = (node: Node): node is HTMLStyleElement | HTMLLinkElement => {\r\n if (node.nodeType !== Node.ELEMENT_NODE) return false\r\n const el = node as Element\r\n return (\r\n el.tagName === 'STYLE' ||\r\n (el.tagName === 'LINK' && (el.getAttribute('rel') ?? '').toLowerCase().includes('stylesheet'))\r\n )\r\n }\r\n\r\n const serializeStyle = (el: HTMLStyleElement): string => {\r\n const sheet = el.sheet\r\n if (sheet) {\r\n try {\r\n return Array.from(sheet.cssRules)\r\n .map((rule) => rule.cssText)\r\n .join('\\n')\r\n } catch {\r\n // Inaccessible cssRules — fall back to the raw text below.\r\n }\r\n }\r\n return el.textContent ?? ''\r\n }\r\n\r\n const mirror = (el: HTMLStyleElement | HTMLLinkElement): void => {\r\n if (mirrors.has(el)) return\r\n let clone: Element\r\n if (el.tagName === 'LINK') {\r\n const link = target.createElement('link')\r\n link.rel = 'stylesheet'\r\n // .href resolves to an absolute URL, so relative hrefs keep working\r\n // from the popup's `about:blank` document.\r\n link.href = (el as HTMLLinkElement).href\r\n const media = el.getAttribute('media')\r\n if (media) link.media = media\r\n clone = link\r\n } else {\r\n const style = target.createElement('style')\r\n style.textContent = serializeStyle(el as HTMLStyleElement)\r\n const media = el.getAttribute('media')\r\n if (media) style.setAttribute('media', media)\r\n clone = style\r\n }\r\n target.head.appendChild(clone)\r\n mirrors.set(el, clone)\r\n }\r\n\r\n const refresh = (el: HTMLStyleElement): void => {\r\n const clone = mirrors.get(el)\r\n if (clone) clone.textContent = serializeStyle(el)\r\n }\r\n\r\n const unmirrorSubtree = (removed: Node): void => {\r\n for (const [src, clone] of mirrors) {\r\n if (removed === src || (removed.nodeType === Node.ELEMENT_NODE && removed.contains(src))) {\r\n clone.remove()\r\n mirrors.delete(src)\r\n }\r\n }\r\n }\r\n\r\n const ROOT_ATTRS = ['class', 'style']\r\n const syncRootAttrs = (): void => {\r\n const pairs: Array<[Element | null, Element | null]> = [\r\n [source.documentElement, target.documentElement],\r\n [source.body, target.body],\r\n ]\r\n for (const [from, to] of pairs) {\r\n if (!from || !to) continue\r\n for (const attr of from.attributes) {\r\n if (ROOT_ATTRS.includes(attr.name) || attr.name.startsWith('data-')) {\r\n to.setAttribute(attr.name, attr.value)\r\n }\r\n }\r\n for (const attr of Array.from(to.attributes)) {\r\n if (\r\n (ROOT_ATTRS.includes(attr.name) || attr.name.startsWith('data-')) &&\r\n !from.hasAttribute(attr.name)\r\n ) {\r\n to.removeAttribute(attr.name)\r\n }\r\n }\r\n }\r\n }\r\n\r\n const syncAdoptedSheets = (): void => {\r\n const targetWindow = target.defaultView as (Window & { CSSStyleSheet?: typeof CSSStyleSheet }) | null\r\n if (!targetWindow?.CSSStyleSheet || !('adoptedStyleSheets' in source)) return\r\n try {\r\n target.adoptedStyleSheets = source.adoptedStyleSheets.map((sheet) => {\r\n const copy = new targetWindow.CSSStyleSheet!()\r\n copy.replaceSync(\r\n Array.from(sheet.cssRules)\r\n .map((rule) => rule.cssText)\r\n .join('\\n'),\r\n )\r\n return copy\r\n })\r\n } catch {\r\n // Best effort — older browsers or cross-origin rules.\r\n }\r\n }\r\n\r\n for (const el of source.querySelectorAll('style, link[rel~=\"stylesheet\" i]')) {\r\n mirror(el as HTMLStyleElement | HTMLLinkElement)\r\n }\r\n syncRootAttrs()\r\n syncAdoptedSheets()\r\n\r\n if (!watch || typeof MutationObserver === 'undefined') {\r\n return () => {}\r\n }\r\n\r\n const headObserver = new MutationObserver((records) => {\r\n for (const record of records) {\r\n if (record.type === 'characterData') {\r\n const parent = record.target.parentElement\r\n if (parent?.tagName === 'STYLE') refresh(parent as HTMLStyleElement)\r\n continue\r\n }\r\n // childList\r\n if (isStyleNode(record.target) && record.target.tagName === 'STYLE') {\r\n // textContent assignment replaces the text node → childList on <style>\r\n refresh(record.target as HTMLStyleElement)\r\n }\r\n for (const added of record.addedNodes) {\r\n if (isStyleNode(added)) {\r\n mirror(added)\r\n } else if (added.nodeType === Node.ELEMENT_NODE) {\r\n for (const el of (added as Element).querySelectorAll('style, link[rel~=\"stylesheet\" i]')) {\r\n mirror(el as HTMLStyleElement | HTMLLinkElement)\r\n }\r\n }\r\n }\r\n for (const removed of record.removedNodes) {\r\n unmirrorSubtree(removed)\r\n }\r\n }\r\n })\r\n headObserver.observe(source.head, { childList: true, subtree: true, characterData: true })\r\n\r\n const rootObserver = new MutationObserver(() => syncRootAttrs())\r\n rootObserver.observe(source.documentElement, { attributes: true })\r\n if (source.body) rootObserver.observe(source.body, { attributes: true })\r\n\r\n return () => {\r\n headObserver.disconnect()\r\n rootObserver.disconnect()\r\n }\r\n}\r\n","import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'\r\nimport type { FC } from 'react'\r\nimport { createPortal } from 'react-dom'\r\nimport { copyStyles } from './copyStyles'\r\nimport type {\r\n PopupProps,\r\n PopupWindowApi,\r\n PopupWindowFeatures,\r\n UsePopupWindowOptions,\r\n} from './types'\r\n\r\ninterface PopupState {\r\n popupWindow: Window | null\r\n container: HTMLElement | null\r\n blocked: boolean\r\n}\r\n\r\nconst INITIAL_STATE: PopupState = { popupWindow: null, container: null, blocked: false }\r\n\r\nclass PopupStore {\r\n state: PopupState = INITIAL_STATE\r\n private listeners = new Set<() => void>()\r\n\r\n subscribe = (listener: () => void): (() => void) => {\r\n this.listeners.add(listener)\r\n return () => this.listeners.delete(listener)\r\n }\r\n\r\n getSnapshot = (): PopupState => this.state\r\n\r\n getServerSnapshot = (): PopupState => INITIAL_STATE\r\n\r\n setState(partial: Partial<PopupState>): void {\r\n this.state = { ...this.state, ...partial }\r\n for (const listener of this.listeners) listener()\r\n }\r\n}\r\n\r\nconst DEFAULT_FEATURES: PopupWindowFeatures = { popup: true, width: 640, height: 480 }\r\n\r\nfunction buildFeatures(options: UsePopupWindowOptions): string {\r\n const merged: PopupWindowFeatures = { ...DEFAULT_FEATURES, ...options.features }\r\n if (\r\n options.center !== false &&\r\n merged.left === undefined &&\r\n merged.top === undefined &&\r\n typeof merged.width === 'number' &&\r\n typeof merged.height === 'number'\r\n ) {\r\n merged.left = Math.max(0, Math.round(window.screenX + (window.outerWidth - merged.width) / 2))\r\n merged.top = Math.max(0, Math.round(window.screenY + (window.outerHeight - merged.height) / 2))\r\n }\r\n return Object.entries(merged)\r\n .filter(([, value]) => value !== undefined && value !== false)\r\n .map(([key, value]) => (value === true ? `${key}=yes` : `${key}=${value}`))\r\n .join(',')\r\n}\r\n\r\nfunction createPopupComponent(store: PopupStore): FC<PopupProps> {\r\n function Popup({ children }: PopupProps) {\r\n const { container } = useSyncExternalStore(\r\n store.subscribe,\r\n store.getSnapshot,\r\n store.getServerSnapshot,\r\n )\r\n return container ? createPortal(children, container) : null\r\n }\r\n Popup.displayName = 'PopupWindow.Popup'\r\n return Popup\r\n}\r\n\r\n/**\r\n * Open part of your React tree in a separate browser window.\r\n *\r\n * The popup content is rendered with a portal into the popup's document, so\r\n * it stays part of your component tree: state, context and event handlers\r\n * all keep working across windows.\r\n *\r\n * ```tsx\r\n * const { open, close, isOpen, Popup } = usePopupWindow({ title: 'Panel' })\r\n *\r\n * return (\r\n * <>\r\n * <button onClick={open}>Open panel</button>\r\n * <Popup>\r\n * <MyPanel />\r\n * </Popup>\r\n * </>\r\n * )\r\n * ```\r\n */\r\nexport function usePopupWindow(options: UsePopupWindowOptions = {}): PopupWindowApi {\r\n const [store] = useState(() => new PopupStore())\r\n const [Popup] = useState(() => createPopupComponent(store))\r\n\r\n const optionsRef = useRef(options)\r\n optionsRef.current = options\r\n\r\n const cleanupRef = useRef<(() => void) | null>(null)\r\n\r\n const state = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getServerSnapshot)\r\n\r\n // `userClosed` is true when the window was closed outside our control\r\n // (user hit the close button, opener unloaded) — then we must not touch it.\r\n const closePopup = useCallback(\r\n (userClosed: boolean) => {\r\n const { popupWindow } = store.getSnapshot()\r\n cleanupRef.current?.()\r\n cleanupRef.current = null\r\n if (!popupWindow) return\r\n if (!userClosed && !popupWindow.closed) popupWindow.close()\r\n store.setState({ popupWindow: null, container: null })\r\n optionsRef.current.onClose?.()\r\n },\r\n [store],\r\n )\r\n\r\n const open = useCallback((): Window | null => {\r\n if (typeof window === 'undefined') return null\r\n const current = store.getSnapshot()\r\n if (current.popupWindow && !current.popupWindow.closed) {\r\n current.popupWindow.focus()\r\n return current.popupWindow\r\n }\r\n\r\n const opts = optionsRef.current\r\n const popupWindow = window.open('about:blank', opts.name ?? '_blank', buildFeatures(opts))\r\n if (!popupWindow) {\r\n store.setState({ blocked: true })\r\n opts.onBlocked?.()\r\n return null\r\n }\r\n\r\n const doc = popupWindow.document\r\n doc.title = opts.title ?? document.title\r\n\r\n let stopStyleSync: (() => void) | undefined\r\n if (opts.copyStyles !== false) {\r\n stopStyleSync = copyStyles(document, doc)\r\n }\r\n\r\n const container = doc.createElement('div')\r\n container.setAttribute('data-popup-window-root', '')\r\n doc.body.appendChild(container)\r\n\r\n const handleExternalClose = () => closePopup(true)\r\n const onPopupPagehide = () => {\r\n // pagehide also fires on navigation; only treat it as a close when the\r\n // window really is gone a tick later.\r\n setTimeout(() => {\r\n if (popupWindow.closed) handleExternalClose()\r\n }, 0)\r\n }\r\n popupWindow.addEventListener('pagehide', onPopupPagehide)\r\n\r\n // Belt and braces: some browsers don't fire pagehide reliably for popups.\r\n const closePoll = window.setInterval(() => {\r\n if (popupWindow.closed) handleExternalClose()\r\n }, 250)\r\n\r\n const onOpenerPagehide = () => popupWindow.close()\r\n window.addEventListener('pagehide', onOpenerPagehide)\r\n\r\n cleanupRef.current = () => {\r\n window.clearInterval(closePoll)\r\n window.removeEventListener('pagehide', onOpenerPagehide)\r\n popupWindow.removeEventListener('pagehide', onPopupPagehide)\r\n stopStyleSync?.()\r\n }\r\n\r\n store.setState({ popupWindow, container, blocked: false })\r\n opts.onOpen?.(popupWindow)\r\n return popupWindow\r\n }, [store, closePopup])\r\n\r\n const close = useCallback(() => closePopup(false), [closePopup])\r\n\r\n const toggle = useCallback(() => {\r\n if (store.getSnapshot().popupWindow) {\r\n closePopup(false)\r\n } else {\r\n open()\r\n }\r\n }, [store, closePopup, open])\r\n\r\n const focus = useCallback(() => {\r\n const { popupWindow } = store.getSnapshot()\r\n if (popupWindow && !popupWindow.closed) popupWindow.focus()\r\n }, [store])\r\n\r\n // Close the popup when the owning component unmounts — its portal content\r\n // would unmount anyway, leaving an empty window behind.\r\n useEffect(() => {\r\n return () => closePopup(false)\r\n }, [closePopup])\r\n\r\n return {\r\n open,\r\n close,\r\n toggle,\r\n focus,\r\n isOpen: state.popupWindow !== null,\r\n isBlocked: state.blocked,\r\n popupWindow: state.popupWindow,\r\n Popup,\r\n }\r\n}\r\n"],"mappings":"iHAgBA,SAAgB,EAAW,EAAkB,EAAkB,EAAQ,GAAkB,CACvF,IAAM,EAAU,IAAI,IAEd,EAAe,GAA2D,CAC9E,GAAI,EAAK,WAAa,KAAK,aAAc,MAAO,GAChD,IAAM,EAAK,EACX,OACE,EAAG,UAAY,SACd,EAAG,UAAY,SAAW,EAAG,aAAa,KAAK,GAAK,GAAA,CAAI,YAAY,CAAC,CAAC,SAAS,YAAY,CAEhG,EAEM,EAAkB,GAAiC,CACvD,IAAM,EAAQ,EAAG,MACjB,GAAI,EACF,GAAI,CACF,OAAO,MAAM,KAAK,EAAM,QAAQ,CAAC,CAC9B,IAAK,GAAS,EAAK,OAAO,CAAC,CAC3B,KAAK;CAAI,CACd,MAAQ,CAER,CAEF,OAAO,EAAG,aAAe,EAC3B,EAEM,EAAU,GAAiD,CAC/D,GAAI,EAAQ,IAAI,CAAE,EAAG,OACrB,IAAI,EACJ,GAAI,EAAG,UAAY,OAAQ,CACzB,IAAM,EAAO,EAAO,cAAc,MAAM,EACxC,EAAK,IAAM,aAGX,EAAK,KAAQ,EAAuB,KACpC,IAAM,EAAQ,EAAG,aAAa,OAAO,EACjC,IAAO,EAAK,MAAQ,GACxB,EAAQ,CACV,KAAO,CACL,IAAM,EAAQ,EAAO,cAAc,OAAO,EAC1C,EAAM,YAAc,EAAe,CAAsB,EACzD,IAAM,EAAQ,EAAG,aAAa,OAAO,EACjC,GAAO,EAAM,aAAa,QAAS,CAAK,EAC5C,EAAQ,CACV,CACA,EAAO,KAAK,YAAY,CAAK,EAC7B,EAAQ,IAAI,EAAI,CAAK,CACvB,EAEM,EAAW,GAA+B,CAC9C,IAAM,EAAQ,EAAQ,IAAI,CAAE,EACxB,IAAO,EAAM,YAAc,EAAe,CAAE,EAClD,EAEM,EAAmB,GAAwB,CAC/C,IAAK,GAAM,CAAC,EAAK,KAAU,GACrB,IAAY,GAAQ,EAAQ,WAAa,KAAK,cAAgB,EAAQ,SAAS,CAAG,KACpF,EAAM,OAAO,EACb,EAAQ,OAAO,CAAG,EAGxB,EAEM,EAAa,CAAC,QAAS,OAAO,EAC9B,MAA4B,CAChC,IAAM,EAAiD,CACrD,CAAC,EAAO,gBAAiB,EAAO,eAAe,EAC/C,CAAC,EAAO,KAAM,EAAO,IAAI,CAC3B,EACA,IAAK,GAAM,CAAC,EAAM,KAAO,EACnB,MAAC,GAAQ,CAAC,GACd,KAAK,IAAM,KAAQ,EAAK,YAClB,EAAW,SAAS,EAAK,IAAI,GAAK,EAAK,KAAK,WAAW,OAAO,IAChE,EAAG,aAAa,EAAK,KAAM,EAAK,KAAK,EAGzC,IAAK,IAAM,KAAQ,MAAM,KAAK,EAAG,UAAU,GAEtC,EAAW,SAAS,EAAK,IAAI,GAAK,EAAK,KAAK,WAAW,OAAO,IAC/D,CAAC,EAAK,aAAa,EAAK,IAAI,GAE5B,EAAG,gBAAgB,EAAK,IAAI,CARS,CAY7C,EAEM,MAAgC,CACpC,IAAM,EAAe,EAAO,YACxB,MAAC,GAAc,eAAiB,EAAE,uBAAwB,IAC9D,GAAI,CACF,EAAO,mBAAqB,EAAO,mBAAmB,IAAK,GAAU,CACnE,IAAM,EAAO,IAAI,EAAa,cAM9B,OALA,EAAK,YACH,MAAM,KAAK,EAAM,QAAQ,CAAC,CACvB,IAAK,GAAS,EAAK,OAAO,CAAC,CAC3B,KAAK;CAAI,CACd,EACO,CACT,CAAC,CACH,MAAQ,CAER,CACF,EAEA,IAAK,IAAM,KAAM,EAAO,iBAAiB,kCAAkC,EACzE,EAAO,CAAwC,EAKjD,GAHA,EAAc,EACd,EAAkB,EAEd,CAAC,GAAS,OAAO,iBAAqB,IACxC,UAAa,CAAC,EAGhB,IAAM,EAAe,IAAI,iBAAkB,GAAY,CACrD,IAAK,IAAM,KAAU,EAAS,CAC5B,GAAI,EAAO,OAAS,gBAAiB,CACnC,IAAM,EAAS,EAAO,OAAO,cACzB,GAAQ,UAAY,SAAS,EAAQ,CAA0B,EACnE,QACF,CAEI,EAAY,EAAO,MAAM,GAAK,EAAO,OAAO,UAAY,SAE1D,EAAQ,EAAO,MAA0B,EAE3C,IAAK,IAAM,KAAS,EAAO,WACzB,GAAI,EAAY,CAAK,EACnB,EAAO,CAAK,OACP,GAAI,EAAM,WAAa,KAAK,aACjC,IAAK,IAAM,KAAO,EAAkB,iBAAiB,kCAAkC,EACrF,EAAO,CAAwC,EAIrD,IAAK,IAAM,KAAW,EAAO,aAC3B,EAAgB,CAAO,CAE3B,CACF,CAAC,EACD,EAAa,QAAQ,EAAO,KAAM,CAAE,UAAW,GAAM,QAAS,GAAM,cAAe,EAAK,CAAC,EAEzF,IAAM,EAAe,IAAI,qBAAuB,EAAc,CAAC,EAI/D,OAHA,EAAa,QAAQ,EAAO,gBAAiB,CAAE,WAAY,EAAK,CAAC,EAC7D,EAAO,MAAM,EAAa,QAAQ,EAAO,KAAM,CAAE,WAAY,EAAK,CAAC,MAE1D,CACX,EAAa,WAAW,EACxB,EAAa,WAAW,CAC1B,CACF,CCtJA,IAAM,EAA4B,CAAE,YAAa,KAAM,UAAW,KAAM,QAAS,EAAM,EAEjF,EAAN,KAAiB,CACf,MAAoB,EACpB,UAAoB,IAAI,IAExB,UAAa,IACX,KAAK,UAAU,IAAI,CAAQ,MACd,KAAK,UAAU,OAAO,CAAQ,GAG7C,gBAAgC,KAAK,MAErC,sBAAsC,EAEtC,SAAS,EAAoC,CAC3C,KAAK,MAAQ,CAAE,GAAG,KAAK,MAAO,GAAG,CAAQ,EACzC,IAAK,IAAM,KAAY,KAAK,UAAW,EAAS,CAClD,CACF,EAEM,EAAwC,CAAE,MAAO,GAAM,MAAO,IAAK,OAAQ,GAAI,EAErF,SAAS,EAAc,EAAwC,CAC7D,IAAM,EAA8B,CAAE,GAAG,EAAkB,GAAG,EAAQ,QAAS,EAW/E,OATE,EAAQ,SAAW,IACnB,EAAO,OAAS,IAAA,IAChB,EAAO,MAAQ,IAAA,IACf,OAAO,EAAO,OAAU,UACxB,OAAO,EAAO,QAAW,WAEzB,EAAO,KAAO,KAAK,IAAI,EAAG,KAAK,MAAM,OAAO,SAAW,OAAO,WAAa,EAAO,OAAS,CAAC,CAAC,EAC7F,EAAO,IAAM,KAAK,IAAI,EAAG,KAAK,MAAM,OAAO,SAAW,OAAO,YAAc,EAAO,QAAU,CAAC,CAAC,GAEzF,OAAO,QAAQ,CAAM,CAAC,CAC1B,QAAQ,EAAG,KAAW,IAAU,IAAA,IAAa,IAAU,EAAK,CAAC,CAC7D,KAAK,CAAC,EAAK,KAAY,IAAU,GAAO,GAAG,EAAI,MAAQ,GAAG,EAAI,GAAG,GAAQ,CAAC,CAC1E,KAAK,GAAG,CACb,CAEA,SAAS,EAAqB,EAAmC,CAC/D,SAAS,EAAM,CAAE,YAAwB,CACvC,GAAM,CAAE,cAAA,EAAc,EAAA,qBAAA,CACpB,EAAM,UACN,EAAM,YACN,EAAM,iBACR,EACA,OAAO,GAAA,EAAY,EAAA,aAAA,CAAa,EAAU,CAAS,EAAI,IACzD,CAEA,MADA,GAAM,YAAc,oBACb,CACT,CAsBA,SAAgB,EAAe,EAAiC,CAAC,EAAmB,CAClF,GAAM,CAAC,IAAA,EAAS,EAAA,SAAA,KAAe,IAAI,CAAY,EACzC,CAAC,IAAA,EAAS,EAAA,SAAA,KAAe,EAAqB,CAAK,CAAC,EAEpD,GAAA,EAAa,EAAA,OAAA,CAAO,CAAO,EACjC,EAAW,QAAU,EAErB,IAAM,GAAA,EAAa,EAAA,OAAA,CAA4B,IAAI,EAE7C,GAAA,EAAQ,EAAA,qBAAA,CAAqB,EAAM,UAAW,EAAM,YAAa,EAAM,iBAAiB,EAIxF,GAAA,EAAa,EAAA,YAAA,CAChB,GAAwB,CACvB,GAAM,CAAE,eAAgB,EAAM,YAAY,EAC1C,EAAW,UAAU,EACrB,EAAW,QAAU,KAChB,IACD,CAAC,GAAc,CAAC,EAAY,QAAQ,EAAY,MAAM,EAC1D,EAAM,SAAS,CAAE,YAAa,KAAM,UAAW,IAAK,CAAC,EACrD,EAAW,QAAQ,UAAU,EAC/B,EACA,CAAC,CAAK,CACR,EAEM,GAAA,EAAO,EAAA,YAAA,KAAiC,CAC5C,GAAI,OAAO,OAAW,IAAa,OAAO,KAC1C,IAAM,EAAU,EAAM,YAAY,EAClC,GAAI,EAAQ,aAAe,CAAC,EAAQ,YAAY,OAE9C,OADA,EAAQ,YAAY,MAAM,EACnB,EAAQ,YAGjB,IAAM,EAAO,EAAW,QAClB,EAAc,OAAO,KAAK,cAAe,EAAK,MAAQ,SAAU,EAAc,CAAI,CAAC,EACzF,GAAI,CAAC,EAGH,OAFA,EAAM,SAAS,CAAE,QAAS,EAAK,CAAC,EAChC,EAAK,YAAY,EACV,KAGT,IAAM,EAAM,EAAY,SACxB,EAAI,MAAQ,EAAK,OAAS,SAAS,MAEnC,IAAI,EACA,EAAK,aAAe,KACtB,EAAgB,EAAW,SAAU,CAAG,GAG1C,IAAM,EAAY,EAAI,cAAc,KAAK,EACzC,EAAU,aAAa,yBAA0B,EAAE,EACnD,EAAI,KAAK,YAAY,CAAS,EAE9B,IAAM,MAA4B,EAAW,EAAI,EAC3C,MAAwB,CAG5B,eAAiB,CACX,EAAY,QAAQ,EAAoB,CAC9C,EAAG,CAAC,CACN,EACA,EAAY,iBAAiB,WAAY,CAAe,EAGxD,IAAM,EAAY,OAAO,gBAAkB,CACrC,EAAY,QAAQ,EAAoB,CAC9C,EAAG,GAAG,EAEA,MAAyB,EAAY,MAAM,EAYjD,OAXA,OAAO,iBAAiB,WAAY,CAAgB,EAEpD,EAAW,YAAgB,CACzB,OAAO,cAAc,CAAS,EAC9B,OAAO,oBAAoB,WAAY,CAAgB,EACvD,EAAY,oBAAoB,WAAY,CAAe,EAC3D,IAAgB,CAClB,EAEA,EAAM,SAAS,CAAE,cAAa,YAAW,QAAS,EAAM,CAAC,EACzD,EAAK,SAAS,CAAW,EAClB,CACT,EAAG,CAAC,EAAO,CAAU,CAAC,EAEhB,GAAA,EAAQ,EAAA,YAAA,KAAkB,EAAW,EAAK,EAAG,CAAC,CAAU,CAAC,EAEzD,GAAA,EAAS,EAAA,YAAA,KAAkB,CAC3B,EAAM,YAAY,CAAC,CAAC,YACtB,EAAW,EAAK,EAEhB,EAAK,CAET,EAAG,CAAC,EAAO,EAAY,CAAI,CAAC,EAEtB,GAAA,EAAQ,EAAA,YAAA,KAAkB,CAC9B,GAAM,CAAE,eAAgB,EAAM,YAAY,EACtC,GAAe,CAAC,EAAY,QAAQ,EAAY,MAAM,CAC5D,EAAG,CAAC,CAAK,CAAC,EAQV,OAJA,EAAA,EAAA,UAAA,SACe,EAAW,EAAK,EAC5B,CAAC,CAAU,CAAC,EAER,CACL,OACA,QACA,SACA,QACA,OAAQ,EAAM,cAAgB,KAC9B,UAAW,EAAM,QACjB,YAAa,EAAM,YACnB,OACF,CACF"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAA;AACjD,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AACzC,YAAY,EACV,UAAU,EACV,cAAc,EACd,mBAAmB,EACnB,qBAAqB,GACtB,MAAM,SAAS,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { useCallback as e, useEffect as t, useRef as n, useState as r, useSyncExternalStore as i } from "react";
|
|
2
|
+
import { createPortal as a } from "react-dom";
|
|
3
|
+
//#region src/copyStyles.ts
|
|
4
|
+
function o(e, t, n = !0) {
|
|
5
|
+
let r = /* @__PURE__ */ new Map(), i = (e) => {
|
|
6
|
+
if (e.nodeType !== Node.ELEMENT_NODE) return !1;
|
|
7
|
+
let t = e;
|
|
8
|
+
return t.tagName === "STYLE" || t.tagName === "LINK" && (t.getAttribute("rel") ?? "").toLowerCase().includes("stylesheet");
|
|
9
|
+
}, a = (e) => {
|
|
10
|
+
let t = e.sheet;
|
|
11
|
+
if (t) try {
|
|
12
|
+
return Array.from(t.cssRules).map((e) => e.cssText).join("\n");
|
|
13
|
+
} catch {}
|
|
14
|
+
return e.textContent ?? "";
|
|
15
|
+
}, o = (e) => {
|
|
16
|
+
if (r.has(e)) return;
|
|
17
|
+
let n;
|
|
18
|
+
if (e.tagName === "LINK") {
|
|
19
|
+
let r = t.createElement("link");
|
|
20
|
+
r.rel = "stylesheet", r.href = e.href;
|
|
21
|
+
let i = e.getAttribute("media");
|
|
22
|
+
i && (r.media = i), n = r;
|
|
23
|
+
} else {
|
|
24
|
+
let r = t.createElement("style");
|
|
25
|
+
r.textContent = a(e);
|
|
26
|
+
let i = e.getAttribute("media");
|
|
27
|
+
i && r.setAttribute("media", i), n = r;
|
|
28
|
+
}
|
|
29
|
+
t.head.appendChild(n), r.set(e, n);
|
|
30
|
+
}, s = (e) => {
|
|
31
|
+
let t = r.get(e);
|
|
32
|
+
t && (t.textContent = a(e));
|
|
33
|
+
}, c = (e) => {
|
|
34
|
+
for (let [t, n] of r) (e === t || e.nodeType === Node.ELEMENT_NODE && e.contains(t)) && (n.remove(), r.delete(t));
|
|
35
|
+
}, l = ["class", "style"], u = () => {
|
|
36
|
+
let n = [[e.documentElement, t.documentElement], [e.body, t.body]];
|
|
37
|
+
for (let [e, t] of n) if (!(!e || !t)) {
|
|
38
|
+
for (let n of e.attributes) (l.includes(n.name) || n.name.startsWith("data-")) && t.setAttribute(n.name, n.value);
|
|
39
|
+
for (let n of Array.from(t.attributes)) (l.includes(n.name) || n.name.startsWith("data-")) && !e.hasAttribute(n.name) && t.removeAttribute(n.name);
|
|
40
|
+
}
|
|
41
|
+
}, d = () => {
|
|
42
|
+
let n = t.defaultView;
|
|
43
|
+
if (!(!n?.CSSStyleSheet || !("adoptedStyleSheets" in e))) try {
|
|
44
|
+
t.adoptedStyleSheets = e.adoptedStyleSheets.map((e) => {
|
|
45
|
+
let t = new n.CSSStyleSheet();
|
|
46
|
+
return t.replaceSync(Array.from(e.cssRules).map((e) => e.cssText).join("\n")), t;
|
|
47
|
+
});
|
|
48
|
+
} catch {}
|
|
49
|
+
};
|
|
50
|
+
for (let t of e.querySelectorAll("style, link[rel~=\"stylesheet\" i]")) o(t);
|
|
51
|
+
if (u(), d(), !n || typeof MutationObserver > "u") return () => {};
|
|
52
|
+
let f = new MutationObserver((e) => {
|
|
53
|
+
for (let t of e) {
|
|
54
|
+
if (t.type === "characterData") {
|
|
55
|
+
let e = t.target.parentElement;
|
|
56
|
+
e?.tagName === "STYLE" && s(e);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
i(t.target) && t.target.tagName === "STYLE" && s(t.target);
|
|
60
|
+
for (let e of t.addedNodes) if (i(e)) o(e);
|
|
61
|
+
else if (e.nodeType === Node.ELEMENT_NODE) for (let t of e.querySelectorAll("style, link[rel~=\"stylesheet\" i]")) o(t);
|
|
62
|
+
for (let e of t.removedNodes) c(e);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
f.observe(e.head, {
|
|
66
|
+
childList: !0,
|
|
67
|
+
subtree: !0,
|
|
68
|
+
characterData: !0
|
|
69
|
+
});
|
|
70
|
+
let p = new MutationObserver(() => u());
|
|
71
|
+
return p.observe(e.documentElement, { attributes: !0 }), e.body && p.observe(e.body, { attributes: !0 }), () => {
|
|
72
|
+
f.disconnect(), p.disconnect();
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
//#endregion
|
|
76
|
+
//#region src/usePopupWindow.ts
|
|
77
|
+
var s = {
|
|
78
|
+
popupWindow: null,
|
|
79
|
+
container: null,
|
|
80
|
+
blocked: !1
|
|
81
|
+
}, c = class {
|
|
82
|
+
state = s;
|
|
83
|
+
listeners = /* @__PURE__ */ new Set();
|
|
84
|
+
subscribe = (e) => (this.listeners.add(e), () => this.listeners.delete(e));
|
|
85
|
+
getSnapshot = () => this.state;
|
|
86
|
+
getServerSnapshot = () => s;
|
|
87
|
+
setState(e) {
|
|
88
|
+
this.state = {
|
|
89
|
+
...this.state,
|
|
90
|
+
...e
|
|
91
|
+
};
|
|
92
|
+
for (let e of this.listeners) e();
|
|
93
|
+
}
|
|
94
|
+
}, l = {
|
|
95
|
+
popup: !0,
|
|
96
|
+
width: 640,
|
|
97
|
+
height: 480
|
|
98
|
+
};
|
|
99
|
+
function u(e) {
|
|
100
|
+
let t = {
|
|
101
|
+
...l,
|
|
102
|
+
...e.features
|
|
103
|
+
};
|
|
104
|
+
return e.center !== !1 && t.left === void 0 && t.top === void 0 && typeof t.width == "number" && typeof t.height == "number" && (t.left = Math.max(0, Math.round(window.screenX + (window.outerWidth - t.width) / 2)), t.top = Math.max(0, Math.round(window.screenY + (window.outerHeight - t.height) / 2))), Object.entries(t).filter(([, e]) => e !== void 0 && e !== !1).map(([e, t]) => t === !0 ? `${e}=yes` : `${e}=${t}`).join(",");
|
|
105
|
+
}
|
|
106
|
+
function d(e) {
|
|
107
|
+
function t({ children: t }) {
|
|
108
|
+
let { container: n } = i(e.subscribe, e.getSnapshot, e.getServerSnapshot);
|
|
109
|
+
return n ? a(t, n) : null;
|
|
110
|
+
}
|
|
111
|
+
return t.displayName = "PopupWindow.Popup", t;
|
|
112
|
+
}
|
|
113
|
+
function f(a = {}) {
|
|
114
|
+
let [s] = r(() => new c()), [l] = r(() => d(s)), f = n(a);
|
|
115
|
+
f.current = a;
|
|
116
|
+
let p = n(null), m = i(s.subscribe, s.getSnapshot, s.getServerSnapshot), h = e((e) => {
|
|
117
|
+
let { popupWindow: t } = s.getSnapshot();
|
|
118
|
+
p.current?.(), p.current = null, t && (!e && !t.closed && t.close(), s.setState({
|
|
119
|
+
popupWindow: null,
|
|
120
|
+
container: null
|
|
121
|
+
}), f.current.onClose?.());
|
|
122
|
+
}, [s]), g = e(() => {
|
|
123
|
+
if (typeof window > "u") return null;
|
|
124
|
+
let e = s.getSnapshot();
|
|
125
|
+
if (e.popupWindow && !e.popupWindow.closed) return e.popupWindow.focus(), e.popupWindow;
|
|
126
|
+
let t = f.current, n = window.open("about:blank", t.name ?? "_blank", u(t));
|
|
127
|
+
if (!n) return s.setState({ blocked: !0 }), t.onBlocked?.(), null;
|
|
128
|
+
let r = n.document;
|
|
129
|
+
r.title = t.title ?? document.title;
|
|
130
|
+
let i;
|
|
131
|
+
t.copyStyles !== !1 && (i = o(document, r));
|
|
132
|
+
let a = r.createElement("div");
|
|
133
|
+
a.setAttribute("data-popup-window-root", ""), r.body.appendChild(a);
|
|
134
|
+
let c = () => h(!0), l = () => {
|
|
135
|
+
setTimeout(() => {
|
|
136
|
+
n.closed && c();
|
|
137
|
+
}, 0);
|
|
138
|
+
};
|
|
139
|
+
n.addEventListener("pagehide", l);
|
|
140
|
+
let d = window.setInterval(() => {
|
|
141
|
+
n.closed && c();
|
|
142
|
+
}, 250), m = () => n.close();
|
|
143
|
+
return window.addEventListener("pagehide", m), p.current = () => {
|
|
144
|
+
window.clearInterval(d), window.removeEventListener("pagehide", m), n.removeEventListener("pagehide", l), i?.();
|
|
145
|
+
}, s.setState({
|
|
146
|
+
popupWindow: n,
|
|
147
|
+
container: a,
|
|
148
|
+
blocked: !1
|
|
149
|
+
}), t.onOpen?.(n), n;
|
|
150
|
+
}, [s, h]), _ = e(() => h(!1), [h]), v = e(() => {
|
|
151
|
+
s.getSnapshot().popupWindow ? h(!1) : g();
|
|
152
|
+
}, [
|
|
153
|
+
s,
|
|
154
|
+
h,
|
|
155
|
+
g
|
|
156
|
+
]), y = e(() => {
|
|
157
|
+
let { popupWindow: e } = s.getSnapshot();
|
|
158
|
+
e && !e.closed && e.focus();
|
|
159
|
+
}, [s]);
|
|
160
|
+
return t(() => () => h(!1), [h]), {
|
|
161
|
+
open: g,
|
|
162
|
+
close: _,
|
|
163
|
+
toggle: v,
|
|
164
|
+
focus: y,
|
|
165
|
+
isOpen: m.popupWindow !== null,
|
|
166
|
+
isBlocked: m.blocked,
|
|
167
|
+
popupWindow: m.popupWindow,
|
|
168
|
+
Popup: l
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
//#endregion
|
|
172
|
+
export { o as copyStyles, f as usePopupWindow };
|
|
173
|
+
|
|
174
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/copyStyles.ts","../src/usePopupWindow.ts"],"sourcesContent":["/**\r\n * Copy all stylesheets from `source` into `target` and (optionally) keep them\r\n * in sync while the popup is open:\r\n *\r\n * - `<link rel=\"stylesheet\">` and `<style>` elements are mirrored into the\r\n * target `<head>`. `<style>` contents are serialized from the CSSOM when\r\n * possible, so rules injected via `insertRule` (CSS-in-JS \"speedy\" mode)\r\n * are included.\r\n * - Additions/removals/edits of style nodes in the source `<head>` are\r\n * observed (covers Vite HMR, lazily loaded chunk CSS, styled-components).\r\n * - `class`/`style`/`data-*` attributes on `<html>` and `<body>` are mirrored\r\n * and kept in sync, so theme switching (e.g. a `dark` class) propagates.\r\n * - `document.adoptedStyleSheets` are re-constructed in the target document.\r\n *\r\n * Returns a function that stops observing.\r\n */\r\nexport function copyStyles(source: Document, target: Document, watch = true): () => void {\r\n const mirrors = new Map<Element, Element>()\r\n\r\n const isStyleNode = (node: Node): node is HTMLStyleElement | HTMLLinkElement => {\r\n if (node.nodeType !== Node.ELEMENT_NODE) return false\r\n const el = node as Element\r\n return (\r\n el.tagName === 'STYLE' ||\r\n (el.tagName === 'LINK' && (el.getAttribute('rel') ?? '').toLowerCase().includes('stylesheet'))\r\n )\r\n }\r\n\r\n const serializeStyle = (el: HTMLStyleElement): string => {\r\n const sheet = el.sheet\r\n if (sheet) {\r\n try {\r\n return Array.from(sheet.cssRules)\r\n .map((rule) => rule.cssText)\r\n .join('\\n')\r\n } catch {\r\n // Inaccessible cssRules — fall back to the raw text below.\r\n }\r\n }\r\n return el.textContent ?? ''\r\n }\r\n\r\n const mirror = (el: HTMLStyleElement | HTMLLinkElement): void => {\r\n if (mirrors.has(el)) return\r\n let clone: Element\r\n if (el.tagName === 'LINK') {\r\n const link = target.createElement('link')\r\n link.rel = 'stylesheet'\r\n // .href resolves to an absolute URL, so relative hrefs keep working\r\n // from the popup's `about:blank` document.\r\n link.href = (el as HTMLLinkElement).href\r\n const media = el.getAttribute('media')\r\n if (media) link.media = media\r\n clone = link\r\n } else {\r\n const style = target.createElement('style')\r\n style.textContent = serializeStyle(el as HTMLStyleElement)\r\n const media = el.getAttribute('media')\r\n if (media) style.setAttribute('media', media)\r\n clone = style\r\n }\r\n target.head.appendChild(clone)\r\n mirrors.set(el, clone)\r\n }\r\n\r\n const refresh = (el: HTMLStyleElement): void => {\r\n const clone = mirrors.get(el)\r\n if (clone) clone.textContent = serializeStyle(el)\r\n }\r\n\r\n const unmirrorSubtree = (removed: Node): void => {\r\n for (const [src, clone] of mirrors) {\r\n if (removed === src || (removed.nodeType === Node.ELEMENT_NODE && removed.contains(src))) {\r\n clone.remove()\r\n mirrors.delete(src)\r\n }\r\n }\r\n }\r\n\r\n const ROOT_ATTRS = ['class', 'style']\r\n const syncRootAttrs = (): void => {\r\n const pairs: Array<[Element | null, Element | null]> = [\r\n [source.documentElement, target.documentElement],\r\n [source.body, target.body],\r\n ]\r\n for (const [from, to] of pairs) {\r\n if (!from || !to) continue\r\n for (const attr of from.attributes) {\r\n if (ROOT_ATTRS.includes(attr.name) || attr.name.startsWith('data-')) {\r\n to.setAttribute(attr.name, attr.value)\r\n }\r\n }\r\n for (const attr of Array.from(to.attributes)) {\r\n if (\r\n (ROOT_ATTRS.includes(attr.name) || attr.name.startsWith('data-')) &&\r\n !from.hasAttribute(attr.name)\r\n ) {\r\n to.removeAttribute(attr.name)\r\n }\r\n }\r\n }\r\n }\r\n\r\n const syncAdoptedSheets = (): void => {\r\n const targetWindow = target.defaultView as (Window & { CSSStyleSheet?: typeof CSSStyleSheet }) | null\r\n if (!targetWindow?.CSSStyleSheet || !('adoptedStyleSheets' in source)) return\r\n try {\r\n target.adoptedStyleSheets = source.adoptedStyleSheets.map((sheet) => {\r\n const copy = new targetWindow.CSSStyleSheet!()\r\n copy.replaceSync(\r\n Array.from(sheet.cssRules)\r\n .map((rule) => rule.cssText)\r\n .join('\\n'),\r\n )\r\n return copy\r\n })\r\n } catch {\r\n // Best effort — older browsers or cross-origin rules.\r\n }\r\n }\r\n\r\n for (const el of source.querySelectorAll('style, link[rel~=\"stylesheet\" i]')) {\r\n mirror(el as HTMLStyleElement | HTMLLinkElement)\r\n }\r\n syncRootAttrs()\r\n syncAdoptedSheets()\r\n\r\n if (!watch || typeof MutationObserver === 'undefined') {\r\n return () => {}\r\n }\r\n\r\n const headObserver = new MutationObserver((records) => {\r\n for (const record of records) {\r\n if (record.type === 'characterData') {\r\n const parent = record.target.parentElement\r\n if (parent?.tagName === 'STYLE') refresh(parent as HTMLStyleElement)\r\n continue\r\n }\r\n // childList\r\n if (isStyleNode(record.target) && record.target.tagName === 'STYLE') {\r\n // textContent assignment replaces the text node → childList on <style>\r\n refresh(record.target as HTMLStyleElement)\r\n }\r\n for (const added of record.addedNodes) {\r\n if (isStyleNode(added)) {\r\n mirror(added)\r\n } else if (added.nodeType === Node.ELEMENT_NODE) {\r\n for (const el of (added as Element).querySelectorAll('style, link[rel~=\"stylesheet\" i]')) {\r\n mirror(el as HTMLStyleElement | HTMLLinkElement)\r\n }\r\n }\r\n }\r\n for (const removed of record.removedNodes) {\r\n unmirrorSubtree(removed)\r\n }\r\n }\r\n })\r\n headObserver.observe(source.head, { childList: true, subtree: true, characterData: true })\r\n\r\n const rootObserver = new MutationObserver(() => syncRootAttrs())\r\n rootObserver.observe(source.documentElement, { attributes: true })\r\n if (source.body) rootObserver.observe(source.body, { attributes: true })\r\n\r\n return () => {\r\n headObserver.disconnect()\r\n rootObserver.disconnect()\r\n }\r\n}\r\n","import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'\r\nimport type { FC } from 'react'\r\nimport { createPortal } from 'react-dom'\r\nimport { copyStyles } from './copyStyles'\r\nimport type {\r\n PopupProps,\r\n PopupWindowApi,\r\n PopupWindowFeatures,\r\n UsePopupWindowOptions,\r\n} from './types'\r\n\r\ninterface PopupState {\r\n popupWindow: Window | null\r\n container: HTMLElement | null\r\n blocked: boolean\r\n}\r\n\r\nconst INITIAL_STATE: PopupState = { popupWindow: null, container: null, blocked: false }\r\n\r\nclass PopupStore {\r\n state: PopupState = INITIAL_STATE\r\n private listeners = new Set<() => void>()\r\n\r\n subscribe = (listener: () => void): (() => void) => {\r\n this.listeners.add(listener)\r\n return () => this.listeners.delete(listener)\r\n }\r\n\r\n getSnapshot = (): PopupState => this.state\r\n\r\n getServerSnapshot = (): PopupState => INITIAL_STATE\r\n\r\n setState(partial: Partial<PopupState>): void {\r\n this.state = { ...this.state, ...partial }\r\n for (const listener of this.listeners) listener()\r\n }\r\n}\r\n\r\nconst DEFAULT_FEATURES: PopupWindowFeatures = { popup: true, width: 640, height: 480 }\r\n\r\nfunction buildFeatures(options: UsePopupWindowOptions): string {\r\n const merged: PopupWindowFeatures = { ...DEFAULT_FEATURES, ...options.features }\r\n if (\r\n options.center !== false &&\r\n merged.left === undefined &&\r\n merged.top === undefined &&\r\n typeof merged.width === 'number' &&\r\n typeof merged.height === 'number'\r\n ) {\r\n merged.left = Math.max(0, Math.round(window.screenX + (window.outerWidth - merged.width) / 2))\r\n merged.top = Math.max(0, Math.round(window.screenY + (window.outerHeight - merged.height) / 2))\r\n }\r\n return Object.entries(merged)\r\n .filter(([, value]) => value !== undefined && value !== false)\r\n .map(([key, value]) => (value === true ? `${key}=yes` : `${key}=${value}`))\r\n .join(',')\r\n}\r\n\r\nfunction createPopupComponent(store: PopupStore): FC<PopupProps> {\r\n function Popup({ children }: PopupProps) {\r\n const { container } = useSyncExternalStore(\r\n store.subscribe,\r\n store.getSnapshot,\r\n store.getServerSnapshot,\r\n )\r\n return container ? createPortal(children, container) : null\r\n }\r\n Popup.displayName = 'PopupWindow.Popup'\r\n return Popup\r\n}\r\n\r\n/**\r\n * Open part of your React tree in a separate browser window.\r\n *\r\n * The popup content is rendered with a portal into the popup's document, so\r\n * it stays part of your component tree: state, context and event handlers\r\n * all keep working across windows.\r\n *\r\n * ```tsx\r\n * const { open, close, isOpen, Popup } = usePopupWindow({ title: 'Panel' })\r\n *\r\n * return (\r\n * <>\r\n * <button onClick={open}>Open panel</button>\r\n * <Popup>\r\n * <MyPanel />\r\n * </Popup>\r\n * </>\r\n * )\r\n * ```\r\n */\r\nexport function usePopupWindow(options: UsePopupWindowOptions = {}): PopupWindowApi {\r\n const [store] = useState(() => new PopupStore())\r\n const [Popup] = useState(() => createPopupComponent(store))\r\n\r\n const optionsRef = useRef(options)\r\n optionsRef.current = options\r\n\r\n const cleanupRef = useRef<(() => void) | null>(null)\r\n\r\n const state = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getServerSnapshot)\r\n\r\n // `userClosed` is true when the window was closed outside our control\r\n // (user hit the close button, opener unloaded) — then we must not touch it.\r\n const closePopup = useCallback(\r\n (userClosed: boolean) => {\r\n const { popupWindow } = store.getSnapshot()\r\n cleanupRef.current?.()\r\n cleanupRef.current = null\r\n if (!popupWindow) return\r\n if (!userClosed && !popupWindow.closed) popupWindow.close()\r\n store.setState({ popupWindow: null, container: null })\r\n optionsRef.current.onClose?.()\r\n },\r\n [store],\r\n )\r\n\r\n const open = useCallback((): Window | null => {\r\n if (typeof window === 'undefined') return null\r\n const current = store.getSnapshot()\r\n if (current.popupWindow && !current.popupWindow.closed) {\r\n current.popupWindow.focus()\r\n return current.popupWindow\r\n }\r\n\r\n const opts = optionsRef.current\r\n const popupWindow = window.open('about:blank', opts.name ?? '_blank', buildFeatures(opts))\r\n if (!popupWindow) {\r\n store.setState({ blocked: true })\r\n opts.onBlocked?.()\r\n return null\r\n }\r\n\r\n const doc = popupWindow.document\r\n doc.title = opts.title ?? document.title\r\n\r\n let stopStyleSync: (() => void) | undefined\r\n if (opts.copyStyles !== false) {\r\n stopStyleSync = copyStyles(document, doc)\r\n }\r\n\r\n const container = doc.createElement('div')\r\n container.setAttribute('data-popup-window-root', '')\r\n doc.body.appendChild(container)\r\n\r\n const handleExternalClose = () => closePopup(true)\r\n const onPopupPagehide = () => {\r\n // pagehide also fires on navigation; only treat it as a close when the\r\n // window really is gone a tick later.\r\n setTimeout(() => {\r\n if (popupWindow.closed) handleExternalClose()\r\n }, 0)\r\n }\r\n popupWindow.addEventListener('pagehide', onPopupPagehide)\r\n\r\n // Belt and braces: some browsers don't fire pagehide reliably for popups.\r\n const closePoll = window.setInterval(() => {\r\n if (popupWindow.closed) handleExternalClose()\r\n }, 250)\r\n\r\n const onOpenerPagehide = () => popupWindow.close()\r\n window.addEventListener('pagehide', onOpenerPagehide)\r\n\r\n cleanupRef.current = () => {\r\n window.clearInterval(closePoll)\r\n window.removeEventListener('pagehide', onOpenerPagehide)\r\n popupWindow.removeEventListener('pagehide', onPopupPagehide)\r\n stopStyleSync?.()\r\n }\r\n\r\n store.setState({ popupWindow, container, blocked: false })\r\n opts.onOpen?.(popupWindow)\r\n return popupWindow\r\n }, [store, closePopup])\r\n\r\n const close = useCallback(() => closePopup(false), [closePopup])\r\n\r\n const toggle = useCallback(() => {\r\n if (store.getSnapshot().popupWindow) {\r\n closePopup(false)\r\n } else {\r\n open()\r\n }\r\n }, [store, closePopup, open])\r\n\r\n const focus = useCallback(() => {\r\n const { popupWindow } = store.getSnapshot()\r\n if (popupWindow && !popupWindow.closed) popupWindow.focus()\r\n }, [store])\r\n\r\n // Close the popup when the owning component unmounts — its portal content\r\n // would unmount anyway, leaving an empty window behind.\r\n useEffect(() => {\r\n return () => closePopup(false)\r\n }, [closePopup])\r\n\r\n return {\r\n open,\r\n close,\r\n toggle,\r\n focus,\r\n isOpen: state.popupWindow !== null,\r\n isBlocked: state.blocked,\r\n popupWindow: state.popupWindow,\r\n Popup,\r\n }\r\n}\r\n"],"mappings":"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"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { FC, ReactNode } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Features passed to `window.open`. Numbers and strings are serialized as
|
|
4
|
+
* `key=value`, `true` as `key=yes`. `false`/`undefined` entries are omitted.
|
|
5
|
+
*/
|
|
6
|
+
export interface PopupWindowFeatures {
|
|
7
|
+
width?: number;
|
|
8
|
+
height?: number;
|
|
9
|
+
left?: number;
|
|
10
|
+
top?: number;
|
|
11
|
+
[feature: string]: string | number | boolean | undefined;
|
|
12
|
+
}
|
|
13
|
+
export interface UsePopupWindowOptions {
|
|
14
|
+
/** Title of the popup window. Defaults to the opener document's title. */
|
|
15
|
+
title?: string;
|
|
16
|
+
/**
|
|
17
|
+
* `window.open` target name. Opening twice with the same name reuses the
|
|
18
|
+
* window. Defaults to `_blank` (a fresh window every time).
|
|
19
|
+
*/
|
|
20
|
+
name?: string;
|
|
21
|
+
/** Extra `window.open` features. Defaults to `{ popup: true, width: 640, height: 480 }` merged with what you pass. */
|
|
22
|
+
features?: PopupWindowFeatures;
|
|
23
|
+
/**
|
|
24
|
+
* Center the popup over the opener window when no explicit `left`/`top`
|
|
25
|
+
* feature is given. Defaults to `true`.
|
|
26
|
+
*/
|
|
27
|
+
center?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Copy the opener document's stylesheets into the popup and keep them in
|
|
30
|
+
* sync (new/changed `<style>`/`<link>` tags, `class`/`style` attributes on
|
|
31
|
+
* `<html>`/`<body>`, adopted stylesheets). Defaults to `true`.
|
|
32
|
+
*/
|
|
33
|
+
copyStyles?: boolean;
|
|
34
|
+
/** Called after the popup window has been opened and prepared. */
|
|
35
|
+
onOpen?: (popupWindow: Window) => void;
|
|
36
|
+
/** Called when the popup closes — via `close()`, the user, or the opener unloading. */
|
|
37
|
+
onClose?: () => void;
|
|
38
|
+
/** Called when `window.open` returns `null` (popup blocked by the browser). */
|
|
39
|
+
onBlocked?: () => void;
|
|
40
|
+
}
|
|
41
|
+
export interface PopupProps {
|
|
42
|
+
children?: ReactNode;
|
|
43
|
+
}
|
|
44
|
+
export interface PopupWindowApi {
|
|
45
|
+
/**
|
|
46
|
+
* Open the popup (focuses it if already open). Call this from a user
|
|
47
|
+
* gesture (e.g. a click handler) or browsers will block it.
|
|
48
|
+
* Returns the `Window`, or `null` when blocked / not in a browser.
|
|
49
|
+
*/
|
|
50
|
+
open: () => Window | null;
|
|
51
|
+
/** Close the popup. No-op when it is not open. */
|
|
52
|
+
close: () => void;
|
|
53
|
+
/** Open if closed, close if open. */
|
|
54
|
+
toggle: () => void;
|
|
55
|
+
/** Focus the popup window. No-op when it is not open. */
|
|
56
|
+
focus: () => void;
|
|
57
|
+
/** Whether the popup window is currently open. */
|
|
58
|
+
isOpen: boolean;
|
|
59
|
+
/** Whether the last `open()` attempt was blocked by the browser. */
|
|
60
|
+
isBlocked: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* The popup `Window` while open, otherwise `null`. Escape hatch for
|
|
63
|
+
* anything window-level — sizing, focus tricks, or `postMessage` when the
|
|
64
|
+
* popup hosts non-React scripts of its own.
|
|
65
|
+
*/
|
|
66
|
+
popupWindow: Window | null;
|
|
67
|
+
/**
|
|
68
|
+
* Portal component with a stable identity. Renders its children into the
|
|
69
|
+
* popup's document while the popup is open, and nothing otherwise.
|
|
70
|
+
*/
|
|
71
|
+
Popup: FC<PopupProps>;
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAE,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAE1C;;;GAGG;AACH,MAAM,WAAW,mBAAmB;IAClC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,SAAS,CAAA;CACzD;AAED,MAAM,WAAW,qBAAqB;IACpC,0EAA0E;IAC1E,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,sHAAsH;IACtH,QAAQ,CAAC,EAAE,mBAAmB,CAAA;IAC9B;;;OAGG;IACH,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,kEAAkE;IAClE,MAAM,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,IAAI,CAAA;IACtC,uFAAuF;IACvF,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IACpB,+EAA+E;IAC/E,SAAS,CAAC,EAAE,MAAM,IAAI,CAAA;CACvB;AAED,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,EAAE,SAAS,CAAA;CACrB;AAED,MAAM,WAAW,cAAc;IAC7B;;;;OAIG;IACH,IAAI,EAAE,MAAM,MAAM,GAAG,IAAI,CAAA;IACzB,kDAAkD;IAClD,KAAK,EAAE,MAAM,IAAI,CAAA;IACjB,qCAAqC;IACrC,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB,yDAAyD;IACzD,KAAK,EAAE,MAAM,IAAI,CAAA;IACjB,kDAAkD;IAClD,MAAM,EAAE,OAAO,CAAA;IACf,oEAAoE;IACpE,SAAS,EAAE,OAAO,CAAA;IAClB;;;;OAIG;IACH,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B;;;OAGG;IACH,KAAK,EAAE,EAAE,CAAC,UAAU,CAAC,CAAA;CACtB"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { PopupWindowApi, UsePopupWindowOptions } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Open part of your React tree in a separate browser window.
|
|
4
|
+
*
|
|
5
|
+
* The popup content is rendered with a portal into the popup's document, so
|
|
6
|
+
* it stays part of your component tree: state, context and event handlers
|
|
7
|
+
* all keep working across windows.
|
|
8
|
+
*
|
|
9
|
+
* ```tsx
|
|
10
|
+
* const { open, close, isOpen, Popup } = usePopupWindow({ title: 'Panel' })
|
|
11
|
+
*
|
|
12
|
+
* return (
|
|
13
|
+
* <>
|
|
14
|
+
* <button onClick={open}>Open panel</button>
|
|
15
|
+
* <Popup>
|
|
16
|
+
* <MyPanel />
|
|
17
|
+
* </Popup>
|
|
18
|
+
* </>
|
|
19
|
+
* )
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export declare function usePopupWindow(options?: UsePopupWindowOptions): PopupWindowApi;
|
|
23
|
+
//# sourceMappingURL=usePopupWindow.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"usePopupWindow.d.ts","sourceRoot":"","sources":["../src/usePopupWindow.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAEV,cAAc,EAEd,qBAAqB,EACtB,MAAM,SAAS,CAAA;AA8DhB;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,cAAc,CAAC,OAAO,GAAE,qBAA0B,GAAG,cAAc,CAmHlF"}
|
package/package.json
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jielga/react-popup-window",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "React hook to open part of your UI in a separate browser popup window — live portal rendering, style syncing and cross-window messaging.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "jielga",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/jielga/react-popup-window.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://jielga.github.io/react-popup-window/",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/jielga/react-popup-window/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"react",
|
|
18
|
+
"hook",
|
|
19
|
+
"popup",
|
|
20
|
+
"window",
|
|
21
|
+
"portal",
|
|
22
|
+
"window.open",
|
|
23
|
+
"multi-window",
|
|
24
|
+
"detached",
|
|
25
|
+
"panel",
|
|
26
|
+
"tanstack-intent"
|
|
27
|
+
],
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"skills",
|
|
32
|
+
"!skills/_artifacts"
|
|
33
|
+
],
|
|
34
|
+
"intent": {
|
|
35
|
+
"docs": "README.md",
|
|
36
|
+
"repo": "Jielga/react-popup-window"
|
|
37
|
+
},
|
|
38
|
+
"main": "./dist/index.cjs",
|
|
39
|
+
"module": "./dist/index.js",
|
|
40
|
+
"types": "./dist/index.d.ts",
|
|
41
|
+
"exports": {
|
|
42
|
+
".": {
|
|
43
|
+
"types": "./dist/index.d.ts",
|
|
44
|
+
"import": "./dist/index.js",
|
|
45
|
+
"require": "./dist/index.cjs"
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"dev": "vite --config docs/vite.config.ts",
|
|
50
|
+
"build": "vite build && tsc -p tsconfig.build.json",
|
|
51
|
+
"docs:build": "vite build --config docs/vite.config.ts",
|
|
52
|
+
"docs:preview": "vite preview --config docs/vite.config.ts",
|
|
53
|
+
"test": "vitest run",
|
|
54
|
+
"test:watch": "vitest",
|
|
55
|
+
"e2e": "playwright test",
|
|
56
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
57
|
+
"skills:validate": "intent validate",
|
|
58
|
+
"changeset": "changeset",
|
|
59
|
+
"version-packages": "changeset version && node scripts/sync-skill-version.mjs",
|
|
60
|
+
"release": "changeset publish",
|
|
61
|
+
"prepublishOnly": "npm run build"
|
|
62
|
+
},
|
|
63
|
+
"peerDependencies": {
|
|
64
|
+
"react": ">=19.2.0",
|
|
65
|
+
"react-dom": ">=19.2.0"
|
|
66
|
+
},
|
|
67
|
+
"publishConfig": {
|
|
68
|
+
"access": "public",
|
|
69
|
+
"provenance": true
|
|
70
|
+
},
|
|
71
|
+
"devDependencies": {
|
|
72
|
+
"@changesets/changelog-github": "^1.0.0",
|
|
73
|
+
"@changesets/cli": "^3.0.1",
|
|
74
|
+
"@jielga/tmdatagrid": "^0.5.0",
|
|
75
|
+
"@mantine/core": "^9.5.1",
|
|
76
|
+
"@mantine/hooks": "^9.5.1",
|
|
77
|
+
"@playwright/test": "^1.62.1",
|
|
78
|
+
"@tabler/icons-react": "^3.46.0",
|
|
79
|
+
"@tanstack/intent": "^0.3.6",
|
|
80
|
+
"@tanstack/react-query": "^5.101.4",
|
|
81
|
+
"@tanstack/react-store": "^0.11.1",
|
|
82
|
+
"@tanstack/react-table": "9.0.0-beta.21",
|
|
83
|
+
"@tanstack/react-virtual": "^3.14.9",
|
|
84
|
+
"@tanstack/store": "^0.11.1",
|
|
85
|
+
"@tanstack/table-core": "9.0.0-beta.21",
|
|
86
|
+
"@testing-library/dom": "^10.4.1",
|
|
87
|
+
"@testing-library/react": "^16.3.2",
|
|
88
|
+
"@types/node": "^26.1.2",
|
|
89
|
+
"@types/react": "^19.2.18",
|
|
90
|
+
"@types/react-dom": "^19.2.4",
|
|
91
|
+
"@vitejs/plugin-react": "^6.0.5",
|
|
92
|
+
"jsdom": "^30.0.1",
|
|
93
|
+
"react": "^19.2.8",
|
|
94
|
+
"react-dom": "^19.2.8",
|
|
95
|
+
"react-resizable-panels": "^4.12.2",
|
|
96
|
+
"typescript": "^7.0.2",
|
|
97
|
+
"vite": "^8.2.1",
|
|
98
|
+
"vitest": "^4.1.10"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: getting-started
|
|
3
|
+
description: >
|
|
4
|
+
Set up @jielga/react-popup-window: the usePopupWindow hook, the Popup portal
|
|
5
|
+
component, window controls (open, close, toggle, focus), reactive state
|
|
6
|
+
(isOpen, isBlocked, popupWindow), and hook options (title, name, features,
|
|
7
|
+
center, copyStyles, onOpen, onClose, onBlocked). Load when opening part of a
|
|
8
|
+
React tree in a separate browser window, building an "open in new window" or
|
|
9
|
+
detachable panel, or when a popup is blocked, closes unexpectedly, or loses
|
|
10
|
+
state.
|
|
11
|
+
metadata:
|
|
12
|
+
type: core
|
|
13
|
+
library: '@jielga/react-popup-window'
|
|
14
|
+
library_version: '0.1.0'
|
|
15
|
+
sources:
|
|
16
|
+
- 'Jielga/react-popup-window:README.md'
|
|
17
|
+
- 'Jielga/react-popup-window:src/usePopupWindow.ts'
|
|
18
|
+
- 'Jielga/react-popup-window:src/types.ts'
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
# react-popup-window — Getting started
|
|
22
|
+
|
|
23
|
+
`usePopupWindow` opens part of a React tree in a separate browser window.
|
|
24
|
+
Content is rendered with a portal into the popup's `about:blank` document,
|
|
25
|
+
so it remains part of the calling component tree: state, context, and event
|
|
26
|
+
handlers work across windows without bridging. The popup document runs no
|
|
27
|
+
JavaScript of its own; the opener's React instance renders into it.
|
|
28
|
+
|
|
29
|
+
## Setup
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
import { usePopupWindow } from '@jielga/react-popup-window'
|
|
33
|
+
|
|
34
|
+
function Dashboard() {
|
|
35
|
+
const { open, close, isOpen, Popup } = usePopupWindow({
|
|
36
|
+
title: 'Detached panel',
|
|
37
|
+
features: { width: 640, height: 480 },
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<>
|
|
42
|
+
<button onClick={open}>Open in new window</button>
|
|
43
|
+
<Popup>
|
|
44
|
+
<MyPanel />
|
|
45
|
+
</Popup>
|
|
46
|
+
</>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`Popup` renders its children into the popup while open and nothing
|
|
52
|
+
otherwise. It has a stable identity; destructuring it from the hook result
|
|
53
|
+
is safe.
|
|
54
|
+
|
|
55
|
+
## Core patterns
|
|
56
|
+
|
|
57
|
+
### Detach a section: hide it inline while popped out
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
const { open, close, focus, isOpen, Popup } = usePopupWindow({ title: 'People' })
|
|
61
|
+
|
|
62
|
+
const table = <DataTable />
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<>
|
|
66
|
+
{isOpen ? (
|
|
67
|
+
<div>
|
|
68
|
+
<button onClick={focus}>Focus window</button>
|
|
69
|
+
<button onClick={close}>Bring back</button>
|
|
70
|
+
</div>
|
|
71
|
+
) : (
|
|
72
|
+
<>
|
|
73
|
+
<button onClick={open}>Open in new window</button>
|
|
74
|
+
{table}
|
|
75
|
+
</>
|
|
76
|
+
)}
|
|
77
|
+
<Popup>{table}</Popup>
|
|
78
|
+
</>
|
|
79
|
+
)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The element is created once and mounted in exactly one of the two homes at
|
|
83
|
+
a time. `isOpen` also flips when the user closes the window by hand, so the
|
|
84
|
+
inline branch restores itself.
|
|
85
|
+
|
|
86
|
+
### Handle blocked popups
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
const { open, isBlocked } = usePopupWindow({ onBlocked: () => notifyUser() })
|
|
90
|
+
// open() returns Window | null; null means blocked (or non-browser environment)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Options
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
interface UsePopupWindowOptions {
|
|
97
|
+
title?: string // popup document title; defaults to the opener's title
|
|
98
|
+
name?: string // window.open target name; same name reuses the window
|
|
99
|
+
features?: PopupWindowFeatures // merged over { popup: true, width: 640, height: 480 }
|
|
100
|
+
center?: boolean // center over the opener window; default true
|
|
101
|
+
copyStyles?: boolean // mirror and live-sync stylesheets; default true
|
|
102
|
+
onOpen?: (popupWindow: Window) => void
|
|
103
|
+
onClose?: () => void // close(), user close, or opener unload
|
|
104
|
+
onBlocked?: () => void
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Return value: `{ open, close, toggle, focus, isOpen, isBlocked, popupWindow,
|
|
109
|
+
Popup }`. `popupWindow` is the raw `Window` while open, otherwise `null`.
|
|
110
|
+
|
|
111
|
+
Lifecycle: the popup closes automatically when the component that owns the
|
|
112
|
+
hook unmounts and when the opener window unloads. Closing the window by
|
|
113
|
+
hand is detected (`pagehide` plus a `closed` poll); `isOpen` flips and the
|
|
114
|
+
portal unmounts.
|
|
115
|
+
|
|
116
|
+
## Common mistakes
|
|
117
|
+
|
|
118
|
+
### HIGH Calling open() outside a user gesture
|
|
119
|
+
|
|
120
|
+
Wrong:
|
|
121
|
+
|
|
122
|
+
```tsx
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
open() // blocked by the popup blocker on mount
|
|
125
|
+
}, [open])
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Correct:
|
|
129
|
+
|
|
130
|
+
```tsx
|
|
131
|
+
<button onClick={open}>Open panel</button>
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Browsers only allow `window.open` in response to a user gesture; outside
|
|
135
|
+
one, `open()` returns `null` and sets `isBlocked` without throwing.
|
|
136
|
+
|
|
137
|
+
Source: README.md, src/usePopupWindow.ts (open)
|
|
138
|
+
|
|
139
|
+
### HIGH Expecting component-local state to survive detaching
|
|
140
|
+
|
|
141
|
+
Wrong:
|
|
142
|
+
|
|
143
|
+
```tsx
|
|
144
|
+
function Panel() {
|
|
145
|
+
const [sort, setSort] = useState('name') // resets when the panel moves windows
|
|
146
|
+
return <SortedList sort={sort} onSort={setSort} />
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Correct:
|
|
151
|
+
|
|
152
|
+
```tsx
|
|
153
|
+
function Owner() {
|
|
154
|
+
const [sort, setSort] = useState('name') // owner stays mounted in the main window
|
|
155
|
+
const { open, isOpen, Popup } = usePopupWindow()
|
|
156
|
+
const panel = <SortedList sort={sort} onSort={setSort} />
|
|
157
|
+
return <>{isOpen ? null : panel}<Popup>{panel}</Popup></>
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Popup content unmounts and remounts when it moves between windows, so state
|
|
162
|
+
held inside it is discarded. State held by the component that owns the hook
|
|
163
|
+
persists, because that component never moves.
|
|
164
|
+
|
|
165
|
+
Source: README.md (Remounting)
|
|
166
|
+
|
|
167
|
+
### MEDIUM Messaging the opener from a portal event handler
|
|
168
|
+
|
|
169
|
+
Wrong:
|
|
170
|
+
|
|
171
|
+
```tsx
|
|
172
|
+
// inside <Popup> content
|
|
173
|
+
<button onClick={() => popupWindow.opener.postMessage(data, '*')}>Send</button>
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Correct:
|
|
177
|
+
|
|
178
|
+
```tsx
|
|
179
|
+
// inside <Popup> content — same tree, same realm; call the handler directly
|
|
180
|
+
<button onClick={() => onData(data)}>Send</button>
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Portal content executes in the main window's JavaScript realm, so the
|
|
184
|
+
message is posted by the main window to itself and `event.source` is the
|
|
185
|
+
main window. `postMessage` is only meaningful for scripts hosted in the
|
|
186
|
+
popup document itself; for portal content, props, state, and context are
|
|
187
|
+
the communication channel.
|
|
188
|
+
|
|
189
|
+
Source: README.md (Communicating with popup content)
|
|
190
|
+
|
|
191
|
+
### MEDIUM Keeping the popup open across owner unmount or navigation
|
|
192
|
+
|
|
193
|
+
Wrong:
|
|
194
|
+
|
|
195
|
+
```tsx
|
|
196
|
+
// route A mounts the hook; navigating to route B is expected to keep the popup
|
|
197
|
+
<Route path="/a" element={<PanelWithPopup />} />
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Correct:
|
|
201
|
+
|
|
202
|
+
```tsx
|
|
203
|
+
// mount the hook in a component that stays mounted across the interaction,
|
|
204
|
+
// e.g. a layout component above the route switch
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
The popup is closed deliberately when the owning component unmounts: its
|
|
208
|
+
portal content would unmount anyway, leaving an empty window. Place the
|
|
209
|
+
hook at a level that lives as long as the popup should.
|
|
210
|
+
|
|
211
|
+
Source: src/usePopupWindow.ts (unmount effect)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: popup-content
|
|
3
|
+
description: >
|
|
4
|
+
Render real application UI inside the popup window: stylesheet synchronization
|
|
5
|
+
(copyStyles, CSSOM serialization, root class and data-* attributes,
|
|
6
|
+
adoptedStyleSheets), theme switching across windows, bounded-height layouts
|
|
7
|
+
for virtualized content, and third-party component libraries whose overlays
|
|
8
|
+
portal to document.body (Mantine, Radix, MUI). Load when popup content renders
|
|
9
|
+
unstyled, a theme change does not reach the popup, or menus, popovers, modals,
|
|
10
|
+
and tooltips open in the main window instead of the popup.
|
|
11
|
+
metadata:
|
|
12
|
+
type: sub-skill
|
|
13
|
+
library: '@jielga/react-popup-window'
|
|
14
|
+
library_version: '0.1.0'
|
|
15
|
+
requires:
|
|
16
|
+
- '@jielga/react-popup-window/getting-started'
|
|
17
|
+
sources:
|
|
18
|
+
- 'Jielga/react-popup-window:README.md'
|
|
19
|
+
- 'Jielga/react-popup-window:src/copyStyles.ts'
|
|
20
|
+
- 'Jielga/react-popup-window:docs/src/examples/SameWindowPortals.tsx'
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
# react-popup-window — Popup content
|
|
24
|
+
|
|
25
|
+
This skill builds on getting-started. Read it first for the portal model
|
|
26
|
+
and hook API.
|
|
27
|
+
|
|
28
|
+
The popup document starts as an unstyled `about:blank` page. While it is
|
|
29
|
+
open, the library mirrors the opener's styling into it and keeps the mirror
|
|
30
|
+
current:
|
|
31
|
+
|
|
32
|
+
- `<style>` and `<link rel="stylesheet">` elements are copied into the
|
|
33
|
+
popup `<head>`. `<style>` contents are serialized from the CSSOM, so
|
|
34
|
+
rules injected with `insertRule` (CSS-in-JS) are included at copy time.
|
|
35
|
+
- Additions, removals, and edits of style nodes in the opener's `<head>`
|
|
36
|
+
are observed and re-mirrored. This covers Vite HMR and lazily loaded
|
|
37
|
+
chunk CSS.
|
|
38
|
+
- `class`, `style`, and `data-*` attributes on `<html>` and `<body>` are
|
|
39
|
+
mirrored and kept in sync. Theme systems keyed on a root class or data
|
|
40
|
+
attribute (for example Mantine's `data-mantine-color-scheme`) follow
|
|
41
|
+
automatically.
|
|
42
|
+
- `document.adoptedStyleSheets` are reconstructed in the popup document.
|
|
43
|
+
|
|
44
|
+
`copyStyles: false` disables all of it. The mechanism is also exported
|
|
45
|
+
standalone as `copyStyles(source, target, watch?)`, returning a `stop`
|
|
46
|
+
function, for windows managed outside the hook.
|
|
47
|
+
|
|
48
|
+
## Setup
|
|
49
|
+
|
|
50
|
+
Redirect portal-based overlays into the popup document. Component libraries
|
|
51
|
+
mount menus, popovers, and tooltips into `document.body`, which is the main
|
|
52
|
+
window's body even for components rendered in the popup. For Mantine, a
|
|
53
|
+
wrapper can supply the correct body through theme default props:
|
|
54
|
+
|
|
55
|
+
```tsx
|
|
56
|
+
import { MantineThemeProvider, Portal } from '@mantine/core'
|
|
57
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
58
|
+
import type { ReactNode } from 'react'
|
|
59
|
+
|
|
60
|
+
export function SameWindowPortals({ children }: { children: ReactNode }) {
|
|
61
|
+
const probeRef = useRef<HTMLDivElement>(null)
|
|
62
|
+
const [target, setTarget] = useState<HTMLElement | null>(null)
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
setTarget(probeRef.current?.ownerDocument.body ?? null)
|
|
66
|
+
}, [])
|
|
67
|
+
|
|
68
|
+
const theme = useMemo(
|
|
69
|
+
() => ({
|
|
70
|
+
components: {
|
|
71
|
+
Portal: Portal.extend({ defaultProps: target ? { target, reuseTargetNode: false } : {} }),
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
[target],
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div ref={probeRef} style={{ display: 'contents' }}>
|
|
79
|
+
<MantineThemeProvider inherit theme={theme}>
|
|
80
|
+
{children}
|
|
81
|
+
</MantineThemeProvider>
|
|
82
|
+
</div>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The wrapper resolves its own `ownerDocument` after mount, so the same
|
|
88
|
+
component works inline (main body, the default behavior) and inside
|
|
89
|
+
`<Popup>` (popup body). For other libraries, use their per-component portal
|
|
90
|
+
container prop (`container` in Radix and MUI) with
|
|
91
|
+
`popupWindow.document.body`.
|
|
92
|
+
|
|
93
|
+
## Core patterns
|
|
94
|
+
|
|
95
|
+
### Full-height popup layout
|
|
96
|
+
|
|
97
|
+
```tsx
|
|
98
|
+
<Popup>
|
|
99
|
+
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
|
100
|
+
<VirtualizedGrid style={{ flex: 1, minHeight: 0 }} />
|
|
101
|
+
</div>
|
|
102
|
+
</Popup>
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The portal container is an unsized `<div>` in the popup body. Content that
|
|
106
|
+
measures itself — virtualized lists, grids, editors — needs an explicit
|
|
107
|
+
bounded height; the popup viewport (`100vh`) is the natural bound.
|
|
108
|
+
|
|
109
|
+
### Theme switching across windows
|
|
110
|
+
|
|
111
|
+
```tsx
|
|
112
|
+
// A root-attribute theme reaches the popup with no additional wiring:
|
|
113
|
+
document.documentElement.classList.toggle('dark', dark)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Root `class` and `data-*` attributes are mirrored while the popup is open,
|
|
117
|
+
so any CSS keyed on them applies in both windows.
|
|
118
|
+
|
|
119
|
+
## Common mistakes
|
|
120
|
+
|
|
121
|
+
### HIGH Overlays from UI libraries open in the main window
|
|
122
|
+
|
|
123
|
+
Wrong:
|
|
124
|
+
|
|
125
|
+
```tsx
|
|
126
|
+
<Popup>
|
|
127
|
+
<DataGrid /> {/* column menu portals to the main window's document.body */}
|
|
128
|
+
</Popup>
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Correct:
|
|
132
|
+
|
|
133
|
+
```tsx
|
|
134
|
+
<Popup>
|
|
135
|
+
<SameWindowPortals>
|
|
136
|
+
<DataGrid />
|
|
137
|
+
</SameWindowPortals>
|
|
138
|
+
</Popup>
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Portal-based overlays default to the global `document.body`. The content
|
|
142
|
+
sits in the popup document, but the overlay mounts — and positions itself —
|
|
143
|
+
in the main window. Supply a portal target inside the popup document.
|
|
144
|
+
|
|
145
|
+
Source: docs/src/examples/SameWindowPortals.tsx
|
|
146
|
+
|
|
147
|
+
### HIGH Unbounded height collapses measured content
|
|
148
|
+
|
|
149
|
+
Wrong:
|
|
150
|
+
|
|
151
|
+
```tsx
|
|
152
|
+
<Popup>
|
|
153
|
+
<VirtualizedGrid style={{ flex: 1, minHeight: 0 }} /> {/* parent has no height */}
|
|
154
|
+
</Popup>
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Correct:
|
|
158
|
+
|
|
159
|
+
```tsx
|
|
160
|
+
<Popup>
|
|
161
|
+
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
|
162
|
+
<VirtualizedGrid style={{ flex: 1, minHeight: 0 }} />
|
|
163
|
+
</div>
|
|
164
|
+
</Popup>
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
`flex: 1` resolves against a sized parent. Without one, a virtualizer
|
|
168
|
+
measures zero height and renders no rows, or the content renders at full
|
|
169
|
+
natural height and scrolls the popup body instead.
|
|
170
|
+
|
|
171
|
+
Source: README.md
|
|
172
|
+
|
|
173
|
+
### MEDIUM Listening on the wrong window object
|
|
174
|
+
|
|
175
|
+
Wrong:
|
|
176
|
+
|
|
177
|
+
```tsx
|
|
178
|
+
// inside <Popup> content
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
window.addEventListener('resize', onResize) // main window: closures keep the opener's globals
|
|
181
|
+
return () => window.removeEventListener('resize', onResize)
|
|
182
|
+
}, [])
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Correct:
|
|
186
|
+
|
|
187
|
+
```tsx
|
|
188
|
+
const { popupWindow } = usePopupWindow(/* ... */)
|
|
189
|
+
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
if (!popupWindow) return
|
|
192
|
+
popupWindow.addEventListener('resize', onResize)
|
|
193
|
+
return () => popupWindow.removeEventListener('resize', onResize)
|
|
194
|
+
}, [popupWindow])
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Portal content executes in the main window's realm; `window` in its
|
|
198
|
+
closures is the opener. Window-level events of the popup — resize, scroll,
|
|
199
|
+
message — require listeners on the `popupWindow` object.
|
|
200
|
+
|
|
201
|
+
Source: README.md (Communicating with popup content)
|
|
202
|
+
|
|
203
|
+
### MEDIUM Expecting CSSOM-only rule changes to sync after open
|
|
204
|
+
|
|
205
|
+
Wrong:
|
|
206
|
+
|
|
207
|
+
```tsx
|
|
208
|
+
// after the popup is open
|
|
209
|
+
someStyleSheet.insertRule('.late { color: red }') // no DOM mutation; not observed
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Correct:
|
|
213
|
+
|
|
214
|
+
```tsx
|
|
215
|
+
// inject a new <style> element instead; node additions are observed
|
|
216
|
+
const el = document.createElement('style')
|
|
217
|
+
el.textContent = '.late { color: red }'
|
|
218
|
+
document.head.appendChild(el)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Style synchronization serializes each sheet when it is copied and re-reads
|
|
222
|
+
it when its DOM node changes. A rule inserted directly into an existing
|
|
223
|
+
sheet's CSSOM after the popup opened produces no mutation and is not
|
|
224
|
+
re-mirrored. Most CSS-in-JS libraries create or update style elements and
|
|
225
|
+
are unaffected.
|
|
226
|
+
|
|
227
|
+
Source: src/copyStyles.ts
|