@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 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
+ [![CI](https://github.com/jielga/react-popup-window/actions/workflows/ci.yml/badge.svg)](https://github.com/jielga/react-popup-window/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/%40jielga%2Freact-popup-window)](https://www.npmjs.com/package/@jielga/react-popup-window)
5
+ [![license](https://img.shields.io/npm/l/%40jielga%2Freact-popup-window)](./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"}
@@ -0,0 +1,4 @@
1
+ export { usePopupWindow } from './usePopupWindow';
2
+ export { copyStyles } from './copyStyles';
3
+ export type { PopupProps, PopupWindowApi, PopupWindowFeatures, UsePopupWindowOptions, } from './types';
4
+ //# sourceMappingURL=index.d.ts.map
@@ -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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"}
@@ -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