Skip to content

Commit 41c13a1

Browse files
authored
feat: allow passing pre-existing DOM elements to slots (#49)
* feat: allow passing pre-existing DOM elements to slots * test coverage
1 parent c7fc732 commit 41c13a1

File tree

4 files changed

+371
-5
lines changed

4 files changed

+371
-5
lines changed

README.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,11 +179,12 @@ Renders a Vue component to a target element. **Returns a Promise** that resolves
179179
**Parameters (in order):**
180180
- **componentName** (string): Name of the registered Vue component
181181
- **props** (object, optional): Props to pass to the component (default: `{}`)
182-
- **slots** (object, optional): Slot content as HTML strings, keyed by slot name (default: `{}`)
182+
- **slots** (object, optional): Slot content as HTML strings OR DOM elements, keyed by slot name (default: `{}`)
183183
- **targetSelector** (string | Element): CSS selector or DOM element where component will be rendered
184184

185185
**Returns:** `Promise<{unmount: Function}>`
186186

187+
**Basic Example (HTML strings):**
187188
```javascript
188189
// Simple component
189190
await nuxtApp.$previewComponent('TestCard', { title: 'My Card' }, {}, '#preview-target');
@@ -200,6 +201,29 @@ await nuxtApp.$previewComponent(
200201
);
201202
```
202203

204+
**Pass pre-exsiting DOM elements to slots**
205+
206+
Slots can also accept pre-existing DOM elements instead of HTML strings. This is useful when:
207+
- Slot content already exists in the DOM (e.g., server-rendered content)
208+
- Processing needs to happen on slot content before Nuxt renders
209+
210+
```javascript
211+
// Extract existing DOM elements to use as slots
212+
const container = document.getElementById('preview-target');
213+
const slotElements = {};
214+
container.querySelectorAll('[data-slot]').forEach(el => {
215+
slotElements[el.dataset.slot] = el; // Pass the element directly
216+
});
217+
218+
await nuxtApp.$previewComponent(
219+
'TwoColumnLayout',
220+
{ width: 50 },
221+
slotElements, // DOM elements instead of strings
222+
'#preview-target'
223+
);
224+
```
225+
See [playground/public/preview-test-dom-slots.html](./playground/public/preview-test-dom-slots.html) for a complete working example.
226+
203227
**Nested Components:** Slots can contain additional preview containers. An example implementing rendering with an
204228
arbitrary depth can be found at the [example](./playground/public/preview-test-loader.html), which can be tested via `npm run dev`.
205229

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1">
6+
<title>Nuxt Component Preview Test - DOM Element Slots</title>
7+
<style>
8+
body {
9+
font-family: Arial, sans-serif;
10+
margin: 20px;
11+
}
12+
.preview-target {
13+
border: 2px dashed #ccc;
14+
min-height: 200px;
15+
padding: 20px;
16+
margin: 20px 0;
17+
background: #fafafa;
18+
}
19+
h2 {
20+
color: #333;
21+
border-bottom: 2px solid #007acc;
22+
padding-bottom: 5px;
23+
}
24+
.info {
25+
background: #e7f3ff;
26+
border-left: 4px solid #007acc;
27+
padding: 10px;
28+
margin: 10px 0;
29+
}
30+
/* Hide slot containers until moved into place */
31+
.visually-hidden {
32+
position: absolute;
33+
width: 1px;
34+
height: 1px;
35+
margin: -1px;
36+
padding: 0;
37+
overflow: hidden;
38+
clip: rect(0, 0, 0, 0);
39+
border: 0;
40+
}
41+
</style>
42+
<script src="/nuxt-component-preview/app-loader.js"></script>
43+
</head>
44+
<body>
45+
<h1>Nuxt Component Preview Test - DOM Element Slots</h1>
46+
47+
<div class="info">
48+
<p><strong>This page tests passing DOM elements as slots instead of HTML strings:</strong></p>
49+
<ul>
50+
<li>Slot content exists in DOM before Nuxt renders</li>
51+
<li>Elements are moved (not cloned) into component slots</li>
52+
<li>Event listeners and JavaScript references are preserved</li>
53+
<li>Hidden with <code>.visually-hidden</code> until moved</li>
54+
</ul>
55+
</div>
56+
57+
<h2>1. Simple DOM Element Slot</h2>
58+
<div id="test-dom-slot-simple" class="preview-target">
59+
<!-- Slot container with DOM content -->
60+
<div class="visually-hidden" data-slot="column-one">
61+
<h3>DOM Element Content</h3>
62+
<p id="test-element">This content is a real DOM element, not an HTML string!</p>
63+
<button id="test-button">Click me to verify event listeners work</button>
64+
</div>
65+
<div class="visually-hidden" data-slot="column-two">
66+
<p>Second column with DOM content</p>
67+
</div>
68+
</div>
69+
70+
<h2>2. Nested Components in DOM Slots</h2>
71+
<div id="test-dom-slot-nested" class="preview-target">
72+
<div class="visually-hidden" data-slot="column-one">
73+
<h3>Nested Component Slot</h3>
74+
<div id="nested-button-dom" class="nuxt-preview-container"
75+
data-component-name="TestButton"
76+
data-component-data='{"element":"TestButton","label":"Nested Button in DOM Slot","variant":"primary"}'>
77+
</div>
78+
</div>
79+
<div class="visually-hidden" data-slot="column-two">
80+
<div id="nested-card-dom" class="nuxt-preview-container"
81+
data-component-name="TestCard"
82+
data-component-data='{"element":"TestCard","title":"Nested Card","description":"In DOM slot"}'>
83+
</div>
84+
</div>
85+
</div>
86+
87+
<h2>3. Mixed: String Slot + DOM Slot</h2>
88+
<div id="test-mixed-slots" class="preview-target">
89+
<div class="visually-hidden" data-slot="column-one">
90+
<p>DOM slot content</p>
91+
</div>
92+
</div>
93+
94+
<script>
95+
const onNuxtComponentPreviewReady = (callback) => window.__nuxtComponentPreviewApp ? callback(window.__nuxtComponentPreviewApp) : window.addEventListener('nuxt-component-preview:ready', event => callback(event.detail.nuxtApp), { once: true })
96+
97+
onNuxtComponentPreviewReady(async (nuxtApp) => {
98+
console.log('Nuxt Component Preview is ready!');
99+
100+
// Test 1: Simple DOM element slots
101+
const container1 = document.getElementById('test-dom-slot-simple');
102+
103+
// Attach event listener to test element before moving
104+
const testButton = document.getElementById('test-button');
105+
let clickCount = 0;
106+
testButton.addEventListener('click', () => {
107+
clickCount++;
108+
alert(`Button clicked ${clickCount} time(s)! Event listener survived the DOM move.`);
109+
});
110+
111+
// Extract slot containers
112+
const slot1Elements = {};
113+
container1.querySelectorAll('[data-slot]').forEach(el => {
114+
slot1Elements[el.dataset.slot] = el;
115+
});
116+
117+
await nuxtApp.$previewComponent(
118+
'TwoColumnLayout',
119+
{ width: 50 },
120+
slot1Elements,
121+
'#test-dom-slot-simple'
122+
);
123+
124+
console.log('✓ Test 1: Simple DOM slots rendered');
125+
126+
// Verify the element was moved (not cloned)
127+
const movedElement = document.getElementById('test-element');
128+
if (movedElement && movedElement.textContent.includes('DOM element')) {
129+
console.log('✓ DOM element successfully moved');
130+
}
131+
132+
// Test 2: Nested components in DOM slots
133+
const container2 = document.getElementById('test-dom-slot-nested');
134+
const slot2Elements = {};
135+
container2.querySelectorAll('[data-slot]').forEach(el => {
136+
slot2Elements[el.dataset.slot] = el;
137+
});
138+
139+
await nuxtApp.$previewComponent(
140+
'TwoColumnLayout',
141+
{ width: 40 },
142+
slot2Elements,
143+
'#test-dom-slot-nested'
144+
);
145+
146+
// Wait for DOM updates
147+
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
148+
await new Promise(resolve => setTimeout(resolve, 50));
149+
150+
// Initialize nested components
151+
const nestedContainers = container2.querySelectorAll('.nuxt-preview-container');
152+
for (const nested of nestedContainers) {
153+
const { element, ...props } = JSON.parse(nested.dataset.componentData || '{}');
154+
await nuxtApp.$previewComponent(nested.dataset.componentName, props, {}, `#${nested.id}`);
155+
}
156+
157+
console.log('✓ Test 2: Nested components in DOM slots rendered');
158+
159+
// Test 3: Mixed - one DOM slot, one string slot
160+
const container3 = document.getElementById('test-mixed-slots');
161+
const slot3Elements = {};
162+
container3.querySelectorAll('[data-slot]').forEach(el => {
163+
slot3Elements[el.dataset.slot] = el;
164+
});
165+
// Add a string slot
166+
slot3Elements['column-two'] = '<h3>String Slot</h3><p>This is an HTML string, not a DOM element</p>';
167+
168+
await nuxtApp.$previewComponent(
169+
'TwoColumnLayout',
170+
{ width: 60 },
171+
slot3Elements,
172+
'#test-mixed-slots'
173+
);
174+
175+
console.log('✓ Test 3: Mixed slots (DOM + string) rendered');
176+
console.log('✓ All tests completed successfully!');
177+
});
178+
</script>
179+
</body>
180+
</html>

src/runtime/components/ComponentPreviewArea.vue

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,34 @@ function renderComponent(preview) {
3434
return h('div', { class: 'preview-error' }, `Component "${element}" not found`)
3535
}
3636
37-
// Convert HTML strings to VNodes for slots
37+
// Convert slots to VNodes (supports both HTML strings and DOM elements)
3838
const slotContent = {}
39-
for (const [slotName, htmlContent] of Object.entries(slots)) {
40-
if (htmlContent) {
41-
slotContent[slotName] = () => h('div', { innerHTML: htmlContent, style: { display: 'contents' } })
39+
for (const [slotName, content] of Object.entries(slots)) {
40+
if (content) {
41+
slotContent[slotName] = () => {
42+
// Check if slot content is a pre-existing DOM element that needs to be moved
43+
if (content instanceof HTMLElement) {
44+
// Use ref callback to move children from the container into the Vue slot.
45+
// Vue calls this function with the mounted DOM element, allowing us to
46+
// imperatively move existing DOM nodes without recreating them.
47+
// This preserves event listeners and JavaScript references.
48+
return h('div', {
49+
ref: (el) => {
50+
if (el && content.childNodes.length > 0) {
51+
// Move all children from the slot container to this Vue slot element
52+
while (content.firstChild) {
53+
el.appendChild(content.firstChild)
54+
}
55+
// Remove the now-empty wrapper
56+
content.remove()
57+
}
58+
},
59+
style: { display: 'contents' },
60+
})
61+
}
62+
// Fallback: Handle HTML string slots (backward compatibility)
63+
return h('div', { innerHTML: content, style: { display: 'contents' } })
64+
}
4265
}
4366
}
4467
return h(component, props, slotContent)

0 commit comments

Comments
 (0)