@jielga/react-popup-window 0.1.0 → 0.1.1

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