From 6fa9b0c971d7388bdd3fea174d9463e61ea412b5 Mon Sep 17 00:00:00 2001 From: Wolfgang Ziegler // fago Date: Sun, 23 Nov 2025 21:55:43 +0100 Subject: [PATCH 1/2] feat: allow passing pre-existing DOM elements to slots --- README.md | 26 ++- package-lock.json | 28 --- playground/public/preview-test-dom-slots.html | 180 ++++++++++++++++++ .../components/ComponentPreviewArea.vue | 31 ++- 4 files changed, 232 insertions(+), 33 deletions(-) create mode 100644 playground/public/preview-test-dom-slots.html diff --git a/README.md b/README.md index 00cfb5e..fe876ea 100644 --- a/README.md +++ b/README.md @@ -179,11 +179,12 @@ Renders a Vue component to a target element. **Returns a Promise** that resolves **Parameters (in order):** - **componentName** (string): Name of the registered Vue component - **props** (object, optional): Props to pass to the component (default: `{}`) -- **slots** (object, optional): Slot content as HTML strings, keyed by slot name (default: `{}`) +- **slots** (object, optional): Slot content as HTML strings OR DOM elements, keyed by slot name (default: `{}`) - **targetSelector** (string | Element): CSS selector or DOM element where component will be rendered **Returns:** `Promise<{unmount: Function}>` +**Basic Example (HTML strings):** ```javascript // Simple component await nuxtApp.$previewComponent('TestCard', { title: 'My Card' }, {}, '#preview-target'); @@ -200,6 +201,29 @@ await nuxtApp.$previewComponent( ); ``` +**Pass pre-exsiting DOM elements to slots** + +Slots can also accept pre-existing DOM elements instead of HTML strings. This is useful when: +- Slot content already exists in the DOM (e.g., server-rendered content) +- Processing needs to happen on slot content before Nuxt renders + +```javascript +// Extract existing DOM elements to use as slots +const container = document.getElementById('preview-target'); +const slotElements = {}; +container.querySelectorAll('[data-slot]').forEach(el => { + slotElements[el.dataset.slot] = el; // Pass the element directly +}); + +await nuxtApp.$previewComponent( + 'TwoColumnLayout', + { width: 50 }, + slotElements, // DOM elements instead of strings + '#preview-target' +); +``` +See [playground/public/preview-test-dom-slots.html](./playground/public/preview-test-dom-slots.html) for a complete working example. + **Nested Components:** Slots can contain additional preview containers. An example implementing rendering with an arbitrary depth can be found at the [example](./playground/public/preview-test-loader.html), which can be tested via `npm run dev`. diff --git a/package-lock.json b/package-lock.json index 2d8a200..a420db3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -77,7 +77,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -1553,7 +1552,6 @@ "integrity": "sha512-WQ751WxWLBIeH3TDFt/LWQ2znyAKxpR5+gpv80oerwnVQs4GKajAfR6dIgExXZkjaPUHEFv2lVD9vM+frbprzw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "c12": "^3.2.0", "citty": "^0.1.6", @@ -4102,7 +4100,6 @@ "integrity": "sha512-IeZF+8H0ns6prg4VrkhgL+yrvDXWDH2cKchrbh80ejG9dQgZWp10epHMbgRuQvgchLII/lfh6Xn3lu6+6L86Hw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.9.0", "@typescript-eslint/types": "^8.46.1", @@ -4165,7 +4162,6 @@ "integrity": "sha512-MX4Zioh39chHlDJbKmEgydJDS3tspMP/lnQC67G3SWsTnb9NeYVWOjkxpOSy4oMfPs4StcWHwBrvUb4ybfnuaw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.8.0" } @@ -4220,7 +4216,6 @@ "integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.3", "@typescript-eslint/types": "8.46.3", @@ -5025,7 +5020,6 @@ "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.21.tgz", "integrity": "sha512-8i+LZ0vf6ZgII5Z9XmUvrCyEzocvWT+TeR2VBUVlzIH6Tyv57E20mPZ1bCS+tbejgUgmjrEh7q/0F0bibskAmw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/parser": "^7.28.3", "@vue/shared": "3.5.21", @@ -5056,7 +5050,6 @@ "integrity": "sha512-SXlyk6I5eUGBd2v8Ie7tF6ADHE9kCR6mBEuPyH1nUZ0h6Xx6nZI29i12sJKQmzbDyr2tUHMhhTt51Z6blbkTTQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/parser": "^7.28.3", "@vue/compiler-core": "3.5.21", @@ -5267,7 +5260,6 @@ "integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "js-beautify": "^1.14.9", "vue-component-type-helpers": "^2.0.0" @@ -5301,7 +5293,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5724,7 +5715,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -7043,7 +7033,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -7115,7 +7104,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8159,7 +8147,6 @@ "integrity": "sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "entities": "^4.5.0", "webidl-conversions": "^7.0.0", @@ -10073,7 +10060,6 @@ "integrity": "sha512-v9+uomgqyLSxlq3qlaMqJJtXg2+rUsa368p/zkmgi5OMGmcZAtZt5GIeSVFF84iNET+08Hdx/rUtd/FyIdfNFQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@oxc-project/types": "^0.86.0" }, @@ -10384,7 +10370,6 @@ "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -10422,7 +10407,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11373,7 +11357,6 @@ "integrity": "sha512-78E9voJHwnXQMiQdiqswVLZwJIzdBKJ1GdI5Zx6XwoFKUIk09/sSrr+05QFzvYb8q6Y9pPV45zzDuYa3907TZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -12270,7 +12253,6 @@ "integrity": "sha512-UYCvU9YQW2f/Vwl+P0GfhxJxbUGLwd+5QrrGgLajzWAtC/23AX0vcise32kkP7Eu0Wu9VlzzHAXkLObgjQfFlQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.14.0", @@ -12504,7 +12486,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12733,7 +12714,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -12980,7 +12960,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -13278,7 +13257,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -13375,7 +13353,6 @@ "integrity": "sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.21", "@vue/compiler-sfc": "3.5.21", @@ -13472,7 +13449,6 @@ "integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0", @@ -13497,7 +13473,6 @@ "integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vue/devtools-api": "^6.6.4" }, @@ -13514,7 +13489,6 @@ "integrity": "sha512-pXx4pkHigOJCzGPXhGA9Rdou1oIuNiF9n4n5GQ7C4QehTXFEpKUjcpvc3PZ6LvC6ccUL021qor8j1153Y7/6Ig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/parser": "^7.27.0" }, @@ -13533,7 +13507,6 @@ "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@volar/typescript": "2.4.15", "@vue/language-core": "2.2.12" @@ -13817,7 +13790,6 @@ "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", "dev": true, "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/playground/public/preview-test-dom-slots.html b/playground/public/preview-test-dom-slots.html new file mode 100644 index 0000000..d198e9a --- /dev/null +++ b/playground/public/preview-test-dom-slots.html @@ -0,0 +1,180 @@ + + + + + + Nuxt Component Preview Test - DOM Element Slots + + + + +

Nuxt Component Preview Test - DOM Element Slots

+ +
+

This page tests passing DOM elements as slots instead of HTML strings:

+ +
+ +

1. Simple DOM Element Slot

+
+ +
+

DOM Element Content

+

This content is a real DOM element, not an HTML string!

+ +
+
+

Second column with DOM content

+
+
+ +

2. Nested Components in DOM Slots

+
+
+

Nested Component Slot

+
+
+
+
+
+
+
+
+ +

3. Mixed: String Slot + DOM Slot

+
+
+

DOM slot content

+
+
+ + + + diff --git a/src/runtime/components/ComponentPreviewArea.vue b/src/runtime/components/ComponentPreviewArea.vue index e0e5500..f8076fa 100644 --- a/src/runtime/components/ComponentPreviewArea.vue +++ b/src/runtime/components/ComponentPreviewArea.vue @@ -34,11 +34,34 @@ function renderComponent(preview) { return h('div', { class: 'preview-error' }, `Component "${element}" not found`) } - // Convert HTML strings to VNodes for slots + // Convert slots to VNodes (supports both HTML strings and DOM elements) const slotContent = {} - for (const [slotName, htmlContent] of Object.entries(slots)) { - if (htmlContent) { - slotContent[slotName] = () => h('div', { innerHTML: htmlContent, style: { display: 'contents' } }) + for (const [slotName, content] of Object.entries(slots)) { + if (content) { + slotContent[slotName] = () => { + // Check if slot content is a pre-existing DOM element that needs to be moved + if (content instanceof HTMLElement) { + // Use ref callback to move children from the container into the Vue slot. + // Vue calls this function with the mounted DOM element, allowing us to + // imperatively move existing DOM nodes without recreating them. + // This preserves event listeners and JavaScript references. + return h('div', { + ref: (el) => { + if (el && content.childNodes.length > 0) { + // Move all children from the slot container to this Vue slot element + while (content.firstChild) { + el.appendChild(content.firstChild) + } + // Remove the now-empty wrapper + content.remove() + } + }, + style: { display: 'contents' }, + }) + } + // Fallback: Handle HTML string slots (backward compatibility) + return h('div', { innerHTML: content, style: { display: 'contents' } }) + } } } return h(component, props, slotContent) From 64cb9155aa7888f3a125daeea86d47b9ccbb6ffe Mon Sep 17 00:00:00 2001 From: Wolfgang Ziegler // fago Date: Sun, 23 Nov 2025 21:59:04 +0100 Subject: [PATCH 2/2] test coverage --- test/preview-dom-slots.test.ts | 139 +++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 test/preview-dom-slots.test.ts diff --git a/test/preview-dom-slots.test.ts b/test/preview-dom-slots.test.ts new file mode 100644 index 0000000..87f805d --- /dev/null +++ b/test/preview-dom-slots.test.ts @@ -0,0 +1,139 @@ +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import { describe, it, expect } from 'vitest' +import { setup, createPage } from '@nuxt/test-utils/e2e' + +describe('preview DOM element slots', async () => { + await setup({ + rootDir: join(fileURLToPath(import.meta.url), '../../playground'), + server: true, + browser: true, + dev: true, + }) + + it('renders components with DOM element slots', async () => { + const page = await createPage('/preview-test-dom-slots.html') + + // Wait for first test to render + await page.waitForFunction(() => { + const target = document.getElementById('test-dom-slot-simple') + return target && target.innerHTML.includes('DOM Element Content') + }, { timeout: 15000 }) + + const hasContent = await page.evaluate(() => { + const target = document.getElementById('test-dom-slot-simple') + return target?.innerHTML.includes('This content is a real DOM element') + }) + + expect(hasContent).toBe(true) + await page.close() + }) + + it('preserves DOM element identity when moving to slots', async () => { + const page = await createPage('/preview-test-dom-slots.html') + + // Wait for component to render + await page.waitForFunction(() => { + return document.getElementById('test-element') !== null + }, { timeout: 15000 }) + + // Verify the element still has its ID (wasn't cloned) + const elementStillExists = await page.evaluate(() => { + const el = document.getElementById('test-element') + return el !== null && el.textContent?.includes('DOM element') + }) + + expect(elementStillExists).toBe(true) + await page.close() + }) + + it('preserves event listeners on moved DOM elements', async () => { + const page = await createPage('/preview-test-dom-slots.html') + + // Wait for button to be rendered + await page.waitForFunction(() => { + return document.getElementById('test-button') !== null + }, { timeout: 15000 }) + + // Set up dialog handler before clicking + page.once('dialog', async (dialog) => { + expect(dialog.message()).toContain('Button clicked 1 time(s)') + await dialog.accept() + }) + + // Click the button + await page.click('#test-button') + + await page.close() + }) + + it('handles nested components in DOM element slots', async () => { + const page = await createPage('/preview-test-dom-slots.html') + + // Wait for nested components to render + await page.waitForFunction(() => { + const nested1 = document.getElementById('nested-button-dom') + const nested2 = document.getElementById('nested-card-dom') + return nested1 && nested1.children.length > 0 + && nested2 && nested2.children.length > 0 + }, { timeout: 20000 }) + + const nestedContent = await page.evaluate(() => { + const button = document.getElementById('nested-button-dom') + const card = document.getElementById('nested-card-dom') + return { + hasButton: button?.innerHTML.includes('Nested Button in DOM Slot') + || button?.querySelector('button')?.textContent?.includes('Nested Button'), + hasCard: card?.innerHTML.includes('Nested Card'), + } + }) + + expect(nestedContent.hasButton).toBe(true) + expect(nestedContent.hasCard).toBe(true) + await page.close() + }) + + it('supports mixing DOM element and string slots', async () => { + const page = await createPage('/preview-test-dom-slots.html') + + // Wait for mixed slot component to render + await page.waitForFunction(() => { + const target = document.getElementById('test-mixed-slots') + return target && target.innerHTML.includes('DOM slot content') + && target.innerHTML.includes('String Slot') + }, { timeout: 15000 }) + + const mixedContent = await page.evaluate(() => { + const target = document.getElementById('test-mixed-slots') + return { + hasDOMSlot: target?.innerHTML.includes('DOM slot content'), + hasStringSlot: target?.innerHTML.includes('String Slot'), + } + }) + + expect(mixedContent.hasDOMSlot).toBe(true) + expect(mixedContent.hasStringSlot).toBe(true) + await page.close() + }) + + it('removes visually-hidden class from slot containers', async () => { + const page = await createPage('/preview-test-dom-slots.html') + + // Wait for rendering + await page.waitForFunction(() => { + return document.getElementById('test-element') !== null + }, { timeout: 15000 }) + + // Check that slot containers with visually-hidden were removed + const hiddenContainersRemaining = await page.evaluate(() => { + // After slots are moved, the original containers should be removed + const container = document.getElementById('test-dom-slot-simple') + const hiddenDivs = container?.querySelectorAll('.visually-hidden[data-slot]') + return hiddenDivs?.length || 0 + }) + + // Should be 0 because containers are removed after moving children + expect(hiddenContainersRemaining).toBe(0) + await page.close() + }) +})