@jielga/react-popup-window 0.1.0 → 0.2.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.
@@ -1,227 +1,428 @@
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
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 — including when a
11
+ portal redirect is already in place but has no effect.
12
+ metadata:
13
+ type: sub-skill
14
+ library: '@jielga/react-popup-window'
15
+ library_version: '0.2.0'
16
+ requires:
17
+ - '@jielga/react-popup-window/getting-started'
18
+ sources:
19
+ - 'Jielga/react-popup-window:README.md'
20
+ - 'Jielga/react-popup-window:src/copyStyles.ts'
21
+ - 'Jielga/react-popup-window:docs/src/examples/SameWindowPortals.tsx'
22
+ ---
23
+
24
+ # react-popup-window — Popup content
25
+
26
+ This skill builds on getting-started. Read it first for the portal model
27
+ and hook API.
28
+
29
+ The popup document starts as an unstyled page: `about:blank`, or the empty
30
+ page set with `url`. While it is open, the library mirrors the opener's
31
+ styling into it and keeps the mirror current:
32
+
33
+ - `<style>` and `<link rel="stylesheet">` elements are copied into the
34
+ popup `<head>`. `<style>` contents are serialized from the CSSOM, so
35
+ rules injected with `insertRule` (CSS-in-JS) are included at copy time.
36
+ - Additions, removals, and edits of style nodes in the opener's `<head>`
37
+ are observed and re-mirrored. This covers Vite HMR and lazily loaded
38
+ chunk CSS.
39
+ - `class`, `style`, and `data-*` attributes on `<html>` and `<body>` are
40
+ mirrored and kept in sync. Theme systems keyed on a root class or data
41
+ attribute (for example Mantine's `data-mantine-color-scheme`) follow
42
+ automatically.
43
+ - `document.adoptedStyleSheets` are reconstructed in the popup document.
44
+
45
+ The popup loads each copied `<link>` stylesheet again. `Popup` renders once
46
+ they have loaded or failed (at most 3 seconds), so the first frame shown is
47
+ styled. Alternate stylesheets and non-matching `media` are not waited for.
48
+ A `<link>` added to the opener while the popup is open loads in the popup
49
+ after the content is shown.
50
+
51
+ `copyStyles: false` disables all of it. The mechanism is also exported
52
+ standalone as `copyStyles(source, target, watch?)`, returning a `stop`
53
+ function, for windows managed outside the hook.
54
+
55
+ ## Setup
56
+
57
+ Redirect portal-based overlays into the popup document. Component libraries
58
+ mount menus, popovers, modals, and tooltips into `document.body`. Popup
59
+ content executes in the opener's realm, so the bare `document` global is
60
+ the main window's document even for components rendered in the popup,
61
+ while `ownerDocument` of a mounted node is the popup's document. For
62
+ Mantine, a wrapper can supply the correct body through theme default
63
+ props:
64
+
65
+ ```tsx
66
+ import { MantineThemeProvider, Portal } from '@mantine/core'
67
+ import { useEffect, useMemo, useRef, useState } from 'react'
68
+ import type { ReactNode } from 'react'
69
+
70
+ export function SameWindowPortals({ children }: { children: ReactNode }) {
71
+ const probeRef = useRef<HTMLDivElement>(null)
72
+ const [target, setTarget] = useState<HTMLElement | null>(null)
73
+
74
+ useEffect(() => {
75
+ setTarget(probeRef.current?.ownerDocument.body ?? null)
76
+ }, [])
77
+
78
+ const theme = useMemo(
79
+ () => ({
80
+ components: {
81
+ Portal: Portal.extend({ defaultProps: target ? { target } : {} }),
82
+ },
83
+ }),
84
+ [target],
85
+ )
86
+
87
+ return (
88
+ <div ref={probeRef} style={{ display: 'contents' }}>
89
+ <MantineThemeProvider inherit theme={theme}>
90
+ {children}
91
+ </MantineThemeProvider>
92
+ </div>
93
+ )
94
+ }
95
+ ```
96
+
97
+ The wrapper resolves its own `ownerDocument` after mount, so the same
98
+ component works inline (main body, the default behavior) and inside
99
+ `<Popup>` (popup body). This covers `Modal` too: it portals through the
100
+ same theme-resolved `Portal`. Because the target resolves in an effect, an
101
+ overlay that opens during the very first render still lands in the main
102
+ body; user-triggered overlays open after mount and are unaffected.
103
+
104
+ For other libraries, use their per-component portal container prop
105
+ (`container` in Radix and MUI) with an element of the popup document:
106
+ `popupWindow.document.body` where the hook's result is in scope, or
107
+ `node.ownerDocument.body` from a ref on any rendered node deeper in the
108
+ tree (the same probe the wrapper uses).
109
+
110
+ The rule is the same for any portal, including a raw `createPortal` in
111
+ application code: the container element must belong to the popup document.
112
+
113
+ To verify a redirect, open the overlay and check its mounted node's
114
+ `ownerDocument`. With an explicit `target`, Mantine portals directly into
115
+ the target and appends no `[data-portal]` node; that node exists only for
116
+ the default, untargeted portal.
117
+
118
+ ## Core patterns
119
+
120
+ ### Full-height popup layout
121
+
122
+ ```tsx
123
+ <Popup>
124
+ <div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
125
+ <VirtualizedGrid style={{ flex: 1, minHeight: 0 }} />
126
+ </div>
127
+ </Popup>
128
+ ```
129
+
130
+ The portal container is an unsized `<div>` in the popup body. Content that
131
+ measures itself — virtualized lists, grids, editors — needs an explicit
132
+ bounded height; the popup viewport (`100vh`) is the natural bound.
133
+
134
+ ### Theme switching across windows
135
+
136
+ ```tsx
137
+ // A root-attribute theme reaches the popup with no additional wiring:
138
+ document.documentElement.classList.toggle('dark', dark)
139
+ ```
140
+
141
+ Root `class` and `data-*` attributes are mirrored while the popup is open,
142
+ so any CSS keyed on them applies in both windows.
143
+
144
+ ## Common mistakes
145
+
146
+ ### HIGH Overlays from UI libraries open in the main window
147
+
148
+ Wrong:
149
+
150
+ ```tsx
151
+ <Popup>
152
+ <DataGrid /> {/* column menu portals to the main window's document.body */}
153
+ </Popup>
154
+ ```
155
+
156
+ Correct:
157
+
158
+ ```tsx
159
+ <Popup>
160
+ <SameWindowPortals>
161
+ <DataGrid />
162
+ </SameWindowPortals>
163
+ </Popup>
164
+ ```
165
+
166
+ Portal-based overlays default to the global `document.body`. The content
167
+ sits in the popup document, but the overlay mounts — and positions itself —
168
+ in the main window. Supply a portal target inside the popup document.
169
+
170
+ Source: docs/src/examples/SameWindowPortals.tsx
171
+
172
+ ### HIGH Portal target resolved outside the Popup
173
+
174
+ Wrong:
175
+
176
+ ```tsx
177
+ <SameWindowPortals> {/* probe mounts in the MAIN document */}
178
+ <Popup>
179
+ <DataGrid />
180
+ </Popup>
181
+ </SameWindowPortals>
182
+ ```
183
+
184
+ Correct:
185
+
186
+ ```tsx
187
+ <Popup>
188
+ <SameWindowPortals>
189
+ <DataGrid />
190
+ </SameWindowPortals>
191
+ </Popup>
192
+ ```
193
+
194
+ Whatever supplies the portal container (a wrapper like this, a ref, a
195
+ `useMemo`) must itself run inside `<Popup>`, because it resolves the
196
+ container from the tree it mounts in.
197
+ Outside `<Popup>` it resolves the main document's body, which is the
198
+ default behavior, so overlays still open in the main window.
199
+
200
+ Source: docs/src/examples/SameWindowPortals.tsx
201
+
202
+ ### HIGH Provider between the redirect and the overlay resets it
203
+
204
+ Wrong:
205
+
206
+ ```tsx
207
+ <Popup>
208
+ <SameWindowPortals>
209
+ <MantineProvider theme={theme}> {/* replaces theme.components */}
210
+ <DataGrid />
211
+ </MantineProvider>
212
+ </SameWindowPortals>
213
+ </Popup>
214
+ ```
215
+
216
+ Correct:
217
+
218
+ ```tsx
219
+ <Popup>
220
+ <SameWindowPortals>
221
+ {/* the main window's providers reach popup content through context */}
222
+ <DataGrid />
223
+ </SameWindowPortals>
224
+ </Popup>
225
+ ```
226
+
227
+ A context-based portal redirect is lost when a provider below it replaces
228
+ that context instead of merging with it.
229
+ In Mantine, `MantineProvider` (and `MantineThemeProvider` without
230
+ `inherit`) rebuilds the theme and drops the `Portal` default props.
231
+ Popup content already receives the main window's providers through context;
232
+ for a local theme override, use `MantineThemeProvider` with `inherit`
233
+ inside the wrapper.
234
+
235
+ Source: docs/src/examples/SameWindowPortals.tsx
236
+
237
+ ### HIGH Expecting Escape and scroll lock to follow a redirected modal
238
+
239
+ Wrong:
240
+
241
+ ```tsx
242
+ // inside <Popup>, portal redirect in place
243
+ <Modal opened={opened} onClose={closeModal} />
244
+ {/* renders in the popup, but Escape pressed there does not close it */}
245
+ ```
246
+
247
+ Correct:
248
+
249
+ ```tsx
250
+ <Modal opened={opened} onClose={closeModal} />
251
+ ```
252
+
253
+ ```tsx
254
+ // plus: bind Escape on the window the content is rendered in
255
+ useEffect(() => {
256
+ if (!opened) return
257
+ const win = hostRef.current?.ownerDocument.defaultView
258
+ const onKeyDown = (e: KeyboardEvent) => e.key === 'Escape' && closeModal()
259
+ win?.addEventListener('keydown', onKeyDown)
260
+ return () => win?.removeEventListener('keydown', onKeyDown)
261
+ }, [opened, closeModal])
262
+ ```
263
+
264
+ A redirected portal moves only the overlay's DOM. Handlers the component
265
+ binds on the bare `window` still attach to the opener: Mantine's `Modal`
266
+ listens for Escape there and locks scroll on the main window's body, so
267
+ Escape pressed in the popup does not close a modal that renders correctly
268
+ in it. (The popup body appears scroll-locked as well only because root
269
+ attributes are mirrored.)
270
+ This cannot be redirected from outside the component; re-implement
271
+ window-level behavior you own with listeners on the popup window.
272
+ Same failure family as "Listening on the wrong window object" below; here
273
+ the listener sits inside the third-party component.
274
+
275
+ Source: docs/src/examples/DataTableExample.tsx, @mantine/core ModalBase (useWindowEvent)
276
+
277
+ ### HIGH Unbounded height collapses measured content
278
+
279
+ Wrong:
280
+
281
+ ```tsx
282
+ <Popup>
283
+ <VirtualizedGrid style={{ flex: 1, minHeight: 0 }} /> {/* parent has no height */}
284
+ </Popup>
285
+ ```
286
+
287
+ Correct:
288
+
289
+ ```tsx
290
+ <Popup>
291
+ <div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
292
+ <VirtualizedGrid style={{ flex: 1, minHeight: 0 }} />
293
+ </div>
294
+ </Popup>
295
+ ```
296
+
297
+ `flex: 1` resolves against a sized parent. Without one, a virtualizer
298
+ measures zero height and renders no rows, or the content renders at full
299
+ natural height and scrolls the popup body instead.
300
+
301
+ Source: README.md
302
+
303
+ ### MEDIUM Per-component portal props override the redirect
304
+
305
+ Wrong:
306
+
307
+ ```tsx
308
+ // inside <SameWindowPortals>
309
+ <Menu portalProps={{ target: document.body }}> {/* the MAIN window's body */}
310
+ ```
311
+
312
+ Correct:
313
+
314
+ ```tsx
315
+ <Menu> {/* no portal props; the redirected default applies */}
316
+ ```
317
+
318
+ A theme- or context-level default applies only where the component does not
319
+ set the prop itself.
320
+ An explicit `portalProps`, `container`, or `target` overrides the
321
+ redirect, including one hardcoded inside an intermediate library
322
+ component.
323
+ `withinPortal={false}` is safe: the overlay renders inline, inside the
324
+ popup document.
325
+
326
+ Source: @mantine/core Portal (theme defaultProps resolution)
327
+
328
+ ### MEDIUM Duplicate copies of the UI library
329
+
330
+ Wrong:
331
+
332
+ ```text
333
+ $ npm ls @mantine/core
334
+ ├── @mantine/core@9.5.1
335
+ └─┬ some-grid-library@2.0.0
336
+ └── @mantine/core@9.4.0
337
+ ```
338
+
339
+ Correct:
340
+
341
+ ```text
342
+ $ npm ls @mantine/core
343
+ └── @mantine/core@9.5.1 # one copy, deduped everywhere
344
+ ```
345
+
346
+ Each installed copy of a UI library creates its own React context, so the
347
+ redirect is written into one copy's context and the overlay reads the
348
+ other's.
349
+ No error is raised; the redirect is silently ignored.
350
+
351
+ Source: @mantine/core (one theme context per installed copy)
352
+
353
+ ### MEDIUM Selector string as portal target
354
+
355
+ Wrong:
356
+
357
+ ```tsx
358
+ Portal.extend({ defaultProps: { target: '#popup-root' } }) // resolved in the MAIN document
359
+ ```
360
+
361
+ Correct:
362
+
363
+ ```tsx
364
+ Portal.extend({ defaultProps: { target: probeRef.current.ownerDocument.body } })
365
+ ```
366
+
367
+ Portal code runs in the opener's realm, so a selector string is resolved
368
+ with the main document's `querySelector` regardless of where the component
369
+ renders.
370
+ Pass an element that belongs to the popup document.
371
+
372
+ Source: @mantine/core Portal (getTargetNode)
373
+
374
+ ### MEDIUM Listening on the wrong window object
375
+
376
+ Wrong:
377
+
378
+ ```tsx
379
+ // inside <Popup> content
380
+ useEffect(() => {
381
+ window.addEventListener('resize', onResize) // main window: closures keep the opener's globals
382
+ return () => window.removeEventListener('resize', onResize)
383
+ }, [])
384
+ ```
385
+
386
+ Correct:
387
+
388
+ ```tsx
389
+ const { popupWindow } = usePopupWindow(/* ... */)
390
+
391
+ useEffect(() => {
392
+ if (!popupWindow) return
393
+ popupWindow.addEventListener('resize', onResize)
394
+ return () => popupWindow.removeEventListener('resize', onResize)
395
+ }, [popupWindow])
396
+ ```
397
+
398
+ Portal content executes in the main window's realm; `window` in its
399
+ closures is the opener. Window-level events of the popup — resize, scroll,
400
+ message — require listeners on the `popupWindow` object.
401
+
402
+ Source: README.md (Communication)
403
+
404
+ ### MEDIUM Expecting CSSOM-only rule changes to sync after open
405
+
406
+ Wrong:
407
+
408
+ ```tsx
409
+ // after the popup is open
410
+ someStyleSheet.insertRule('.late { color: red }') // no DOM mutation; not observed
411
+ ```
412
+
413
+ Correct:
414
+
415
+ ```tsx
416
+ // inject a new <style> element instead; node additions are observed
417
+ const el = document.createElement('style')
418
+ el.textContent = '.late { color: red }'
419
+ document.head.appendChild(el)
420
+ ```
421
+
422
+ Style synchronization serializes each sheet when it is copied and re-reads
423
+ it when its DOM node changes. A rule inserted directly into an existing
424
+ sheet's CSSOM after the popup opened produces no mutation and is not
425
+ re-mirrored. Most CSS-in-JS libraries create or update style elements and
426
+ are unaffected.
427
+
428
+ Source: src/copyStyles.ts