react-magic-portal 1.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/.commitlintrc +5 -0
- package/.github/dependabot.yml +11 -0
- package/.github/workflows/cd.yml +85 -0
- package/.github/workflows/ci.yml +65 -0
- package/.husky/commit-msg +1 -0
- package/.husky/pre-commit +1 -0
- package/.prettierrc +6 -0
- package/.releaserc +13 -0
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +186 -0
- package/__tests__/eslint.config.ts +25 -0
- package/__tests__/package.json +42 -0
- package/__tests__/src/MagicPortal.test.tsx +506 -0
- package/__tests__/tsconfig.json +23 -0
- package/__tests__/vite.config.ts +11 -0
- package/eslint.config.mts +16 -0
- package/package.json +64 -0
- package/packages/component/.prettierrc +6 -0
- package/packages/component/README.md +6 -0
- package/packages/component/dist/index.d.ts +27 -0
- package/packages/component/dist/index.d.ts.map +1 -0
- package/packages/component/dist/index.js +2 -0
- package/packages/component/dist/index.js.map +1 -0
- package/packages/component/eslint.config.ts +25 -0
- package/packages/component/package.json +70 -0
- package/packages/component/src/index.ts +123 -0
- package/packages/component/tsconfig.json +27 -0
- package/packages/example/.prettierrc +6 -0
- package/packages/example/README.md +6 -0
- package/packages/example/eslint.config.ts +25 -0
- package/packages/example/index.html +13 -0
- package/packages/example/package.json +32 -0
- package/packages/example/pnpm-lock.yaml +2098 -0
- package/packages/example/public/vite.svg +1 -0
- package/packages/example/src/App.css +332 -0
- package/packages/example/src/App.tsx +82 -0
- package/packages/example/src/assets/react.svg +1 -0
- package/packages/example/src/components/portal-content.tsx +33 -0
- package/packages/example/src/index.css +68 -0
- package/packages/example/src/main.tsx +13 -0
- package/packages/example/src/vite-env.d.ts +1 -0
- package/packages/example/tsconfig.json +25 -0
- package/packages/example/vite.config.ts +7 -0
- package/pnpm-workspace.yaml +3 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
import { useRef, useState } from 'react'
|
|
2
|
+
import { render, screen, waitFor } from '@testing-library/react'
|
|
3
|
+
import userEvent from '@testing-library/user-event'
|
|
4
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
5
|
+
import MagicPortal from 'react-magic-portal'
|
|
6
|
+
|
|
7
|
+
describe('MagicPortal', () => {
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
document.body.innerHTML = ''
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
vi.clearAllMocks()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
describe('Basic Functionality', () => {
|
|
17
|
+
it('should render children in portal when anchor exists', () => {
|
|
18
|
+
// Setup anchor element
|
|
19
|
+
const anchor = document.createElement('div')
|
|
20
|
+
anchor.id = 'test-anchor'
|
|
21
|
+
document.body.appendChild(anchor)
|
|
22
|
+
|
|
23
|
+
render(
|
|
24
|
+
<MagicPortal anchor="#test-anchor">
|
|
25
|
+
<div data-testid="portal-content">Portal Content</div>
|
|
26
|
+
</MagicPortal>
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
const portalContent = screen.getByTestId('portal-content')
|
|
30
|
+
expect(portalContent).toBeTruthy()
|
|
31
|
+
expect(anchor.contains(portalContent)).toBe(true)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('should not render children when anchor does not exist', () => {
|
|
35
|
+
render(
|
|
36
|
+
<MagicPortal anchor="#non-existent">
|
|
37
|
+
<div data-testid="portal-content">Portal Content</div>
|
|
38
|
+
</MagicPortal>
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
expect(screen.queryByTestId('portal-content')).toBeNull()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('should render children when anchor appears later', async () => {
|
|
45
|
+
function TestComponent() {
|
|
46
|
+
const [showAnchor, setShowAnchor] = useState(false)
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div>
|
|
50
|
+
<button onClick={() => setShowAnchor(true)}>Show Anchor</button>
|
|
51
|
+
{showAnchor && <div id="magic-anchor">Anchor</div>}
|
|
52
|
+
<MagicPortal anchor="#magic-anchor">
|
|
53
|
+
<div data-testid="portal-content">Portal Content</div>
|
|
54
|
+
</MagicPortal>
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const user = userEvent.setup()
|
|
60
|
+
render(<TestComponent />)
|
|
61
|
+
|
|
62
|
+
expect(screen.queryByTestId('portal-content')).toBeNull()
|
|
63
|
+
|
|
64
|
+
await user.click(screen.getByText('Show Anchor'))
|
|
65
|
+
|
|
66
|
+
await waitFor(() => {
|
|
67
|
+
expect(screen.getByTestId('portal-content')).toBeTruthy()
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('should clean up when anchor is removed', async () => {
|
|
72
|
+
function TestComponent() {
|
|
73
|
+
const [showAnchor, setShowAnchor] = useState(true)
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div>
|
|
77
|
+
<button onClick={() => setShowAnchor(false)}>Hide Anchor</button>
|
|
78
|
+
{showAnchor && <div id="magic-anchor">Anchor</div>}
|
|
79
|
+
<MagicPortal anchor="#magic-anchor">
|
|
80
|
+
<div data-testid="portal-content">Portal Content</div>
|
|
81
|
+
</MagicPortal>
|
|
82
|
+
</div>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const user = userEvent.setup()
|
|
87
|
+
render(<TestComponent />)
|
|
88
|
+
|
|
89
|
+
expect(screen.getByTestId('portal-content')).toBeTruthy()
|
|
90
|
+
|
|
91
|
+
await user.click(screen.getByText('Hide Anchor'))
|
|
92
|
+
|
|
93
|
+
await waitFor(() => {
|
|
94
|
+
expect(screen.queryByTestId('portal-content')).toBeNull()
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('Anchor Types', () => {
|
|
100
|
+
it('should work with CSS selector string', () => {
|
|
101
|
+
const anchor = document.createElement('div')
|
|
102
|
+
anchor.className = 'test-class'
|
|
103
|
+
document.body.appendChild(anchor)
|
|
104
|
+
|
|
105
|
+
render(
|
|
106
|
+
<MagicPortal anchor=".test-class">
|
|
107
|
+
<div data-testid="portal-content">Portal Content</div>
|
|
108
|
+
</MagicPortal>
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
const portalContent = screen.getByTestId('portal-content')
|
|
112
|
+
expect(portalContent).toBeTruthy()
|
|
113
|
+
expect(anchor.contains(portalContent)).toBe(true)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('should work with element reference', () => {
|
|
117
|
+
function TestComponent() {
|
|
118
|
+
const anchorRef = useRef<HTMLDivElement>(null)
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<div>
|
|
122
|
+
<div ref={anchorRef} data-testid="anchor">
|
|
123
|
+
Anchor
|
|
124
|
+
</div>
|
|
125
|
+
<MagicPortal anchor={anchorRef}>
|
|
126
|
+
<div data-testid="portal-content">Portal Content</div>
|
|
127
|
+
</MagicPortal>
|
|
128
|
+
</div>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
render(<TestComponent />)
|
|
133
|
+
|
|
134
|
+
const portalContent = screen.getByTestId('portal-content')
|
|
135
|
+
const anchor = screen.getByTestId('anchor')
|
|
136
|
+
expect(portalContent).toBeTruthy()
|
|
137
|
+
expect(anchor.contains(portalContent)).toBe(true)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('should work with function returning element', () => {
|
|
141
|
+
const anchor = document.createElement('div')
|
|
142
|
+
anchor.id = 'function-anchor'
|
|
143
|
+
document.body.appendChild(anchor)
|
|
144
|
+
|
|
145
|
+
render(
|
|
146
|
+
<MagicPortal anchor={() => document.getElementById('function-anchor')}>
|
|
147
|
+
<div data-testid="portal-content">Portal Content</div>
|
|
148
|
+
</MagicPortal>
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
const portalContent = screen.getByTestId('portal-content')
|
|
152
|
+
expect(portalContent).toBeTruthy()
|
|
153
|
+
expect(anchor.contains(portalContent)).toBe(true)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('should work with direct element', () => {
|
|
157
|
+
const anchor = document.createElement('div')
|
|
158
|
+
document.body.appendChild(anchor)
|
|
159
|
+
|
|
160
|
+
render(
|
|
161
|
+
<MagicPortal anchor={anchor}>
|
|
162
|
+
<div data-testid="portal-content">Portal Content</div>
|
|
163
|
+
</MagicPortal>
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
const portalContent = screen.getByTestId('portal-content')
|
|
167
|
+
expect(portalContent).toBeTruthy()
|
|
168
|
+
expect(anchor.contains(portalContent)).toBe(true)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('should handle null anchor gracefully', () => {
|
|
172
|
+
render(
|
|
173
|
+
<MagicPortal anchor={null}>
|
|
174
|
+
<div data-testid="portal-content">Portal Content</div>
|
|
175
|
+
</MagicPortal>
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
expect(screen.queryByTestId('portal-content')).toBeNull()
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('Position Options', () => {
|
|
183
|
+
beforeEach(() => {
|
|
184
|
+
const anchor = document.createElement('div')
|
|
185
|
+
anchor.id = 'position-anchor'
|
|
186
|
+
anchor.innerHTML = '<span>Existing Content</span>'
|
|
187
|
+
document.body.appendChild(anchor)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('should append by default', () => {
|
|
191
|
+
render(
|
|
192
|
+
<MagicPortal anchor="#position-anchor">
|
|
193
|
+
<div data-testid="portal-content">Portal Content</div>
|
|
194
|
+
</MagicPortal>
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
const anchor = document.getElementById('position-anchor')!
|
|
198
|
+
const portalContent = screen.getByTestId('portal-content')
|
|
199
|
+
|
|
200
|
+
expect(anchor.contains(portalContent)).toBe(true)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('should prepend when position is prepend', () => {
|
|
204
|
+
render(
|
|
205
|
+
<MagicPortal anchor="#position-anchor" position="prepend">
|
|
206
|
+
<div data-testid="portal-content">Portal Content</div>
|
|
207
|
+
</MagicPortal>
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
const anchor = document.getElementById('position-anchor')!
|
|
211
|
+
const portalContent = screen.getByTestId('portal-content')
|
|
212
|
+
|
|
213
|
+
expect(anchor.contains(portalContent)).toBe(true)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('should position before when position is before', () => {
|
|
217
|
+
render(
|
|
218
|
+
<MagicPortal anchor="#position-anchor" position="before">
|
|
219
|
+
<div data-testid="portal-content">Portal Content</div>
|
|
220
|
+
</MagicPortal>
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
const anchor = document.getElementById('position-anchor')!
|
|
224
|
+
const portalContent = screen.getByTestId('portal-content')
|
|
225
|
+
|
|
226
|
+
expect(anchor.parentElement!.contains(portalContent)).toBe(true)
|
|
227
|
+
// The portal content container should be before the anchor
|
|
228
|
+
const portalContainer = portalContent.parentElement!
|
|
229
|
+
expect(portalContainer.nextElementSibling).toBe(anchor)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('should position after when position is after', () => {
|
|
233
|
+
render(
|
|
234
|
+
<MagicPortal anchor="#position-anchor" position="after">
|
|
235
|
+
<div data-testid="portal-content">Portal Content</div>
|
|
236
|
+
</MagicPortal>
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
const anchor = document.getElementById('position-anchor')!
|
|
240
|
+
const portalContent = screen.getByTestId('portal-content')
|
|
241
|
+
|
|
242
|
+
expect(anchor.parentElement!.contains(portalContent)).toBe(true)
|
|
243
|
+
// The portal content container should be after the anchor
|
|
244
|
+
const portalContainer = portalContent.parentElement!
|
|
245
|
+
expect(portalContainer.previousElementSibling).toBe(anchor)
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
describe('Lifecycle Callbacks', () => {
|
|
250
|
+
it('should call onMount when portal is mounted', async () => {
|
|
251
|
+
const onMount = vi.fn()
|
|
252
|
+
const anchor = document.createElement('div')
|
|
253
|
+
anchor.id = 'mount-anchor'
|
|
254
|
+
document.body.appendChild(anchor)
|
|
255
|
+
|
|
256
|
+
render(
|
|
257
|
+
<MagicPortal anchor="#mount-anchor" onMount={onMount}>
|
|
258
|
+
<div data-testid="portal-content">Portal Content</div>
|
|
259
|
+
</MagicPortal>
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
await waitFor(() => {
|
|
263
|
+
expect(onMount).toHaveBeenCalledTimes(1)
|
|
264
|
+
expect(onMount).toHaveBeenCalledWith(anchor, expect.any(HTMLDivElement))
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('should call onUnmount when component is unmounted', async () => {
|
|
269
|
+
const onUnmount = vi.fn()
|
|
270
|
+
const anchor = document.createElement('div')
|
|
271
|
+
anchor.id = 'unmount-anchor'
|
|
272
|
+
document.body.appendChild(anchor)
|
|
273
|
+
|
|
274
|
+
const { unmount } = render(
|
|
275
|
+
<MagicPortal anchor="#unmount-anchor" onUnmount={onUnmount}>
|
|
276
|
+
<div data-testid="portal-content">Portal Content</div>
|
|
277
|
+
</MagicPortal>
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
expect(screen.getByTestId('portal-content')).toBeTruthy()
|
|
281
|
+
|
|
282
|
+
unmount()
|
|
283
|
+
|
|
284
|
+
expect(onUnmount).toHaveBeenCalledTimes(1)
|
|
285
|
+
expect(onUnmount).toHaveBeenCalledWith(anchor, expect.any(HTMLDivElement))
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('should call both onMount and onUnmount during anchor changes', async () => {
|
|
289
|
+
const onMount = vi.fn()
|
|
290
|
+
const onUnmount = vi.fn()
|
|
291
|
+
|
|
292
|
+
// Create anchor element that will persist
|
|
293
|
+
const anchor = document.createElement('div')
|
|
294
|
+
anchor.id = 'toggle-anchor'
|
|
295
|
+
|
|
296
|
+
const { unmount } = render(
|
|
297
|
+
<MagicPortal anchor="#toggle-anchor" onMount={onMount} onUnmount={onUnmount}>
|
|
298
|
+
<div data-testid="portal-content">Portal Content</div>
|
|
299
|
+
</MagicPortal>
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
// No anchor exists initially
|
|
303
|
+
expect(screen.queryByTestId('portal-content')).toBeNull()
|
|
304
|
+
expect(onMount).not.toHaveBeenCalled()
|
|
305
|
+
|
|
306
|
+
// Add anchor to DOM
|
|
307
|
+
document.body.appendChild(anchor)
|
|
308
|
+
|
|
309
|
+
// Wait for portal to mount
|
|
310
|
+
await waitFor(() => {
|
|
311
|
+
expect(screen.getByTestId('portal-content')).toBeTruthy()
|
|
312
|
+
expect(onMount).toHaveBeenCalledTimes(1)
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
// Unmount component to trigger onUnmount
|
|
316
|
+
unmount()
|
|
317
|
+
expect(onUnmount).toHaveBeenCalledTimes(1)
|
|
318
|
+
})
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
describe('Ref Handling', () => {
|
|
322
|
+
it('should forward ref to portal container', () => {
|
|
323
|
+
const ref: { current: HTMLDivElement | null } = { current: null }
|
|
324
|
+
const anchor = document.createElement('div')
|
|
325
|
+
anchor.id = 'ref-anchor'
|
|
326
|
+
document.body.appendChild(anchor)
|
|
327
|
+
|
|
328
|
+
render(
|
|
329
|
+
<MagicPortal anchor="#ref-anchor" ref={ref}>
|
|
330
|
+
<div data-testid="portal-content">Portal Content</div>
|
|
331
|
+
</MagicPortal>
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
expect(ref.current).toBeInstanceOf(HTMLDivElement)
|
|
335
|
+
expect(ref.current?.style.display).toBe('contents')
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it('should handle function refs', () => {
|
|
339
|
+
let refElement: HTMLDivElement | null = null
|
|
340
|
+
const refCallback = (element: HTMLDivElement | null) => {
|
|
341
|
+
refElement = element
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const anchor = document.createElement('div')
|
|
345
|
+
anchor.id = 'func-ref-anchor'
|
|
346
|
+
document.body.appendChild(anchor)
|
|
347
|
+
|
|
348
|
+
render(
|
|
349
|
+
<MagicPortal anchor="#func-ref-anchor" ref={refCallback}>
|
|
350
|
+
<div data-testid="portal-content">Portal Content</div>
|
|
351
|
+
</MagicPortal>
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
expect(refElement).toBeInstanceOf(HTMLDivElement)
|
|
355
|
+
expect(refElement!.style.display).toBe('contents')
|
|
356
|
+
})
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
describe('Multiple Portals', () => {
|
|
360
|
+
it('should support multiple portals on the same anchor', () => {
|
|
361
|
+
const anchor = document.createElement('div')
|
|
362
|
+
anchor.id = 'multi-anchor'
|
|
363
|
+
document.body.appendChild(anchor)
|
|
364
|
+
|
|
365
|
+
render(
|
|
366
|
+
<div>
|
|
367
|
+
<MagicPortal anchor="#multi-anchor" position="prepend">
|
|
368
|
+
<div data-testid="portal-1">Portal 1</div>
|
|
369
|
+
</MagicPortal>
|
|
370
|
+
<MagicPortal anchor="#multi-anchor" position="append">
|
|
371
|
+
<div data-testid="portal-2">Portal 2</div>
|
|
372
|
+
</MagicPortal>
|
|
373
|
+
</div>
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
const portal1 = screen.getByTestId('portal-1')
|
|
377
|
+
const portal2 = screen.getByTestId('portal-2')
|
|
378
|
+
expect(portal1).toBeTruthy()
|
|
379
|
+
expect(portal2).toBeTruthy()
|
|
380
|
+
expect(anchor.contains(portal1)).toBe(true)
|
|
381
|
+
expect(anchor.contains(portal2)).toBe(true)
|
|
382
|
+
})
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
describe('Magic Content Updates', () => {
|
|
386
|
+
it('should detect when matching elements are added to DOM', async () => {
|
|
387
|
+
function TestComponent() {
|
|
388
|
+
const [elementCount, setElementCount] = useState(0)
|
|
389
|
+
|
|
390
|
+
const addElement = () => {
|
|
391
|
+
const newElement = document.createElement('div')
|
|
392
|
+
newElement.className = 'magic-target'
|
|
393
|
+
newElement.textContent = `Target ${elementCount + 1}`
|
|
394
|
+
document.body.appendChild(newElement)
|
|
395
|
+
setElementCount((prev) => prev + 1)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return (
|
|
399
|
+
<div>
|
|
400
|
+
<button onClick={addElement}>Add Target Element</button>
|
|
401
|
+
<MagicPortal anchor=".magic-target">
|
|
402
|
+
<div data-testid="portal-content">Portal Content</div>
|
|
403
|
+
</MagicPortal>
|
|
404
|
+
</div>
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const user = userEvent.setup()
|
|
409
|
+
render(<TestComponent />)
|
|
410
|
+
|
|
411
|
+
expect(screen.queryByTestId('portal-content')).toBeNull()
|
|
412
|
+
|
|
413
|
+
await user.click(screen.getByText('Add Target Element'))
|
|
414
|
+
|
|
415
|
+
await waitFor(() => {
|
|
416
|
+
expect(screen.getByTestId('portal-content')).toBeTruthy()
|
|
417
|
+
})
|
|
418
|
+
})
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
describe('Error Handling', () => {
|
|
422
|
+
it('should handle invalid selectors gracefully', () => {
|
|
423
|
+
expect(() => {
|
|
424
|
+
render(
|
|
425
|
+
<MagicPortal anchor="invalid>>>selector">
|
|
426
|
+
<div data-testid="portal-content">Portal Content</div>
|
|
427
|
+
</MagicPortal>
|
|
428
|
+
)
|
|
429
|
+
}).not.toThrow()
|
|
430
|
+
|
|
431
|
+
expect(screen.queryByTestId('portal-content')).toBeNull()
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
it('should handle function that returns null', () => {
|
|
435
|
+
render(
|
|
436
|
+
<MagicPortal anchor={() => null}>
|
|
437
|
+
<div data-testid="portal-content">Portal Content</div>
|
|
438
|
+
</MagicPortal>
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
expect(screen.queryByTestId('portal-content')).toBeNull()
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
it('should handle function that throws error', () => {
|
|
445
|
+
const errorFunction = () => {
|
|
446
|
+
throw new Error('Test error')
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// The component should handle the error gracefully and not crash
|
|
450
|
+
expect(() => {
|
|
451
|
+
render(
|
|
452
|
+
<MagicPortal anchor={errorFunction}>
|
|
453
|
+
<div data-testid="portal-content">Portal Content</div>
|
|
454
|
+
</MagicPortal>
|
|
455
|
+
)
|
|
456
|
+
}).toThrow('Test error')
|
|
457
|
+
|
|
458
|
+
// Portal content should not be rendered when anchor function throws
|
|
459
|
+
expect(screen.queryByTestId('portal-content')).toBeNull()
|
|
460
|
+
})
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
describe('Key Prop', () => {
|
|
464
|
+
it('should pass key to ReactDOM.createPortal', () => {
|
|
465
|
+
const anchor = document.createElement('div')
|
|
466
|
+
anchor.id = 'key-anchor'
|
|
467
|
+
document.body.appendChild(anchor)
|
|
468
|
+
|
|
469
|
+
const { rerender } = render(
|
|
470
|
+
<MagicPortal anchor="#key-anchor" key="test-key-1">
|
|
471
|
+
<div data-testid="portal-content-1">Portal Content 1</div>
|
|
472
|
+
</MagicPortal>
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
expect(screen.getByTestId('portal-content-1')).toBeTruthy()
|
|
476
|
+
|
|
477
|
+
rerender(
|
|
478
|
+
<MagicPortal anchor="#key-anchor" key="test-key-2">
|
|
479
|
+
<div data-testid="portal-content-2">Portal Content 2</div>
|
|
480
|
+
</MagicPortal>
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
expect(screen.queryByTestId('portal-content-1')).toBeNull()
|
|
484
|
+
expect(screen.getByTestId('portal-content-2')).toBeTruthy()
|
|
485
|
+
})
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
describe('Cleanup', () => {
|
|
489
|
+
it('should clean up MutationObserver on unmount', () => {
|
|
490
|
+
const anchor = document.createElement('div')
|
|
491
|
+
anchor.id = 'cleanup-anchor'
|
|
492
|
+
document.body.appendChild(anchor)
|
|
493
|
+
|
|
494
|
+
const { unmount } = render(
|
|
495
|
+
<MagicPortal anchor="#cleanup-anchor">
|
|
496
|
+
<div data-testid="portal-content">Portal Content</div>
|
|
497
|
+
</MagicPortal>
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
expect(screen.getByTestId('portal-content')).toBeTruthy()
|
|
501
|
+
|
|
502
|
+
expect(() => unmount()).not.toThrow()
|
|
503
|
+
expect(screen.queryByTestId('portal-content')).toBeNull()
|
|
504
|
+
})
|
|
505
|
+
})
|
|
506
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"baseUrl": ".",
|
|
4
|
+
"target": "ES2020",
|
|
5
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
6
|
+
"allowJs": true,
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"allowSyntheticDefaultImports": true,
|
|
10
|
+
"strict": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"noFallthroughCasesInSwitch": true,
|
|
13
|
+
"module": "ESNext",
|
|
14
|
+
"moduleResolution": "bundler",
|
|
15
|
+
"resolveJsonModule": true,
|
|
16
|
+
"isolatedModules": true,
|
|
17
|
+
"noEmit": true,
|
|
18
|
+
"jsx": "react-jsx",
|
|
19
|
+
"types": ["vitest/globals"]
|
|
20
|
+
},
|
|
21
|
+
"include": ["src/**/*", "vite.config.ts", "eslint.config.ts"],
|
|
22
|
+
"exclude": ["node_modules"]
|
|
23
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import js from "@eslint/js";
|
|
2
|
+
import globals from "globals";
|
|
3
|
+
import tseslint from "typescript-eslint";
|
|
4
|
+
import pluginReact from "eslint-plugin-react";
|
|
5
|
+
import { defineConfig } from "eslint/config";
|
|
6
|
+
|
|
7
|
+
export default defineConfig([
|
|
8
|
+
{
|
|
9
|
+
files: ["**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
|
|
10
|
+
plugins: { js },
|
|
11
|
+
extends: ["js/recommended"],
|
|
12
|
+
languageOptions: { globals: { ...globals.browser, ...globals.node } },
|
|
13
|
+
},
|
|
14
|
+
tseslint.configs.recommended,
|
|
15
|
+
pluginReact.configs.flat.recommended,
|
|
16
|
+
]);
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "react-magic-portal",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "React Portal with dynamic mounting support",
|
|
5
|
+
"main": "packages/component/dist/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"dev": "pnpm --filter react-magic-portal dev",
|
|
9
|
+
"build": "pnpm --filter react-magic-portal build",
|
|
10
|
+
"dev:example": "pnpm --filter example dev",
|
|
11
|
+
"lint": "npm-run-all -p lint:*",
|
|
12
|
+
"check": "npm-run-all -p check:*",
|
|
13
|
+
"test": "pnpm --filter __tests__ test",
|
|
14
|
+
"test:ui": "pnpm --filter __tests__ test:ui",
|
|
15
|
+
"lint:component": "pnpm --filter react-magic-portal lint",
|
|
16
|
+
"lint:example": "pnpm --filter example lint",
|
|
17
|
+
"lint:tests": "pnpm --filter __tests__ lint",
|
|
18
|
+
"check:component": "pnpm --filter react-magic-portal check",
|
|
19
|
+
"check:example": "pnpm --filter example check",
|
|
20
|
+
"check:tests": "pnpm --filter __tests__ check",
|
|
21
|
+
"prepare": "husky"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"react",
|
|
25
|
+
"portal",
|
|
26
|
+
"dynamic",
|
|
27
|
+
"browser-extension",
|
|
28
|
+
"content-script",
|
|
29
|
+
"mutation-observer",
|
|
30
|
+
"inject",
|
|
31
|
+
"mount",
|
|
32
|
+
"anchor",
|
|
33
|
+
"reactdom",
|
|
34
|
+
"createportal",
|
|
35
|
+
"web-extension",
|
|
36
|
+
"chrome-extension",
|
|
37
|
+
"firefox-extension",
|
|
38
|
+
"dynamic-content",
|
|
39
|
+
"dom-manipulation",
|
|
40
|
+
"typescript"
|
|
41
|
+
],
|
|
42
|
+
"author": "molvqingtai",
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "https://github.com/molvqingtai/react-magic-portal.git"
|
|
47
|
+
},
|
|
48
|
+
"bugs": {
|
|
49
|
+
"url": "https://github.com/molvqingtai/react-magic-portal/issues"
|
|
50
|
+
},
|
|
51
|
+
"homepage": "https://github.com/molvqingtai/react-magic-portal#readme",
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"@commitlint/cli": "^19.8.1",
|
|
54
|
+
"@commitlint/config-conventional": "^19.8.1",
|
|
55
|
+
"@semantic-release/changelog": "^6.0.3",
|
|
56
|
+
"@semantic-release/git": "^10.0.1",
|
|
57
|
+
"husky": "^9.1.7",
|
|
58
|
+
"npm-run-all": "^4.1.5",
|
|
59
|
+
"semantic-release": "^24.2.9"
|
|
60
|
+
},
|
|
61
|
+
"publishConfig": {
|
|
62
|
+
"access": "public"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
//#region src/index.d.ts
|
|
4
|
+
interface MagicPortalProps {
|
|
5
|
+
anchor: string | (() => Element | null) | Element | React.RefObject<Element | null> | null;
|
|
6
|
+
position?: 'append' | 'prepend' | 'before' | 'after';
|
|
7
|
+
children: React.ReactNode;
|
|
8
|
+
onMount?: (anchor: Element, container: HTMLDivElement) => void;
|
|
9
|
+
onUnmount?: (anchor: Element, container: HTMLDivElement) => void;
|
|
10
|
+
ref?: React.Ref<HTMLDivElement | null>;
|
|
11
|
+
key?: React.Key;
|
|
12
|
+
}
|
|
13
|
+
declare const MagicPortal: {
|
|
14
|
+
({
|
|
15
|
+
anchor,
|
|
16
|
+
position,
|
|
17
|
+
children,
|
|
18
|
+
onMount,
|
|
19
|
+
onUnmount,
|
|
20
|
+
ref,
|
|
21
|
+
key
|
|
22
|
+
}: MagicPortalProps): React.ReactPortal | null;
|
|
23
|
+
displayName: string;
|
|
24
|
+
};
|
|
25
|
+
//#endregion
|
|
26
|
+
export { MagicPortalProps, MagicPortal as default };
|
|
27
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"sourcesContent":[],"mappings":";;;UAGiB,gBAAA;0BACS,kBAAkB,UAAU,KAAA,CAAM,UAAU;EADrD,QAAA,CAAA,EAAA,QAAgB,GAAA,SAAA,GAAA,QAAA,GAAA,OAAA;EAAA,QAAA,EAGrB,KAAA,CAAM,SAHe;SACP,CAAA,EAAA,CAAA,MAAA,EAGL,OAHK,EAAA,SAAA,EAGe,cAHf,EAAA,GAAA,IAAA;WAAkB,CAAA,EAAA,CAAA,MAAA,EAIrB,OAJqB,EAAA,SAAA,EAID,cAJC,EAAA,GAAA,IAAA;KAA0B,CAAA,EAK9D,KAAA,CAAM,GALwD,CAKpD,cALoD,GAAA,IAAA,CAAA;KAAhB,CAAA,EAM9C,KAAA,CAAM,GAN8C;;cAStD,WANe,EAAA;;IAAoB,MAAA;IAAA,QAAA;IAAA,QAAA;IAAA,OAAA;IAAA,SAAA;IAAA,GAAA;IAAA;EAAA,CAAA,EAMqD,gBANrD,CAAA,EAMqE,KAAA,CAAA,WANrE,GAAA,IAAA;aAClB,EAAA,MAAA"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{useCallback as e,useEffect as t,useRef as n,useState as r}from"react";import i from"react-dom";const a=({anchor:a,position:o=`append`,children:s,onMount:c,onUnmount:l,ref:u,key:d})=>{let[f,p]=r(null),m=n(null),h=e(e=>{u&&(typeof u==`function`?u(e):u.current=e)},[u]),g=e(e=>{let t=document.createElement(`div`);return t.style.display=`contents`,e.insertAdjacentElement({before:`beforebegin`,prepend:`afterbegin`,append:`beforeend`,after:`afterend`}[o],t)},[o]),_=e(()=>typeof a==`string`?document.querySelector(a):typeof a==`function`?a():a&&`current`in a?a.current:a,[a]),v=e(()=>{let e=_();p(t=>{t?.remove(),m.current=e;let n=e?g(e):null;return h(n),n})},[_,g,h]);return t(()=>{v();let e=new MutationObserver(e=>{e.some(e=>{let{addedNodes:t,removedNodes:n}=e;return m.current&&Array.from(n).includes(m.current)?!0:typeof a==`string`?Array.from(t).some(e=>e.nodeType===Node.ELEMENT_NODE&&e instanceof Element&&e.matches?.(a)):!1})&&v()});return e.observe(document.body,{childList:!0,subtree:!0}),()=>e.disconnect()},[v,a]),t(()=>{if(m.current&&f)return c?.(m.current,f),()=>{l?.(m.current,f)}},[f,c,l]),f?i.createPortal(s,f,d):null};a.displayName=`MagicPortal`;var o=a;export{o as default};
|
|
2
|
+
//# sourceMappingURL=index.js.map
|