From ab3eb8fb4724b11daa06390bcbfae2ff1190f009 Mon Sep 17 00:00:00 2001 From: Cory Rylan Date: Fri, 31 Jul 2026 15:16:49 -0500 Subject: [PATCH] feat(code): iframe component - Added the nve-iframe component to render isolated content with dynamic height and theme synchronization. - Implemented iframe styling and functionality to handle resizing and theme updates. Signed-off-by: Cory Rylan --- projects/code/DEVELOPMENT.md | 1 + projects/code/package.json | 38 ++- projects/code/src/bundle.ts | 2 + projects/code/src/iframe/define.ts | 13 + projects/code/src/iframe/iframe.css | 18 ++ projects/code/src/iframe/iframe.examples.ts | 112 ++++++++ projects/code/src/iframe/iframe.test.axe.ts | 29 +++ .../code/src/iframe/iframe.test.lighthouse.ts | 21 ++ projects/code/src/iframe/iframe.test.ssr.ts | 24 ++ projects/code/src/iframe/iframe.test.ts | 240 ++++++++++++++++++ projects/code/src/iframe/iframe.ts | 231 +++++++++++++++++ projects/code/src/iframe/index.ts | 4 + projects/code/src/index.test.lighthouse.ts | 3 +- projects/code/vitest.ssr.ts | 15 ++ .../src/local/example-approved-domains.js | 2 +- projects/site/src/_11ty/layouts/common.js | 1 + projects/site/src/docs/code/iframe.md | 40 +++ 17 files changed, 791 insertions(+), 3 deletions(-) create mode 100644 projects/code/src/iframe/define.ts create mode 100644 projects/code/src/iframe/iframe.css create mode 100644 projects/code/src/iframe/iframe.examples.ts create mode 100644 projects/code/src/iframe/iframe.test.axe.ts create mode 100644 projects/code/src/iframe/iframe.test.lighthouse.ts create mode 100644 projects/code/src/iframe/iframe.test.ssr.ts create mode 100644 projects/code/src/iframe/iframe.test.ts create mode 100644 projects/code/src/iframe/iframe.ts create mode 100644 projects/code/src/iframe/index.ts create mode 100644 projects/code/vitest.ssr.ts create mode 100644 projects/site/src/docs/code/iframe.md diff --git a/projects/code/DEVELOPMENT.md b/projects/code/DEVELOPMENT.md index 599835d4a1..ff4b5b3d0d 100644 --- a/projects/code/DEVELOPMENT.md +++ b/projects/code/DEVELOPMENT.md @@ -8,6 +8,7 @@ | `pnpm run test` | Run unit tests | | `pnpm run test:watch` | Run unit tests in watch mode | | `pnpm run test:axe` | Run accessibility tests | +| `pnpm run test:ssr` | Run server-side rendering tests | | `pnpm run test:coverage` | Run unit tests with coverage | | `pnpm run test:lighthouse` | Run Lighthouse performance tests | | `pnpm run ci` | Run the full CI pipeline | diff --git a/projects/code/package.json b/projects/code/package.json index 8313755b7e..f2ea7f56d0 100644 --- a/projects/code/package.json +++ b/projects/code/package.json @@ -4,7 +4,7 @@ "release": { "extends": "../../release.config.js" }, - "description": "Code authoring related components. Providing syntax-highlighted code blocks with support for multiple programming languages.", + "description": "NVIDIA Elements code authoring related components such as syntax-highlighted code blocks and an iframe isolation components.", "keywords": [ "nvidia", "web-components", @@ -169,6 +169,18 @@ "./codeblock/languages/yaml.js": { "types": "./dist/codeblock/languages/yaml.d.ts", "default": "./dist/codeblock/languages/yaml.js" + }, + "./iframe": { + "types": "./dist/iframe/index.d.ts", + "default": "./dist/iframe/index.js" + }, + "./iframe/index.js": { + "types": "./dist/iframe/index.d.ts", + "default": "./dist/iframe/index.js" + }, + "./iframe/define.js": { + "types": "./dist/iframe/define.d.ts", + "default": "./dist/iframe/define.js" } }, "sideEffects": [ @@ -184,6 +196,7 @@ "lint": "wireit", "test": "wireit", "test:axe": "wireit", + "test:ssr": "wireit", "test:coverage": "wireit", "test:watch": "wireit", "test:lighthouse": "wireit" @@ -233,6 +246,7 @@ "lint", "publint", "test:axe", + "test:ssr", "test:coverage" ] }, @@ -368,6 +382,28 @@ "NODE_ENV": "production" } }, + "test:ssr": { + "command": "vitest run --config=vitest.ssr.ts", + "files": [ + "dist/**/*.js", + "src/**/*.test.ssr.ts", + "tsconfig.json", + "vitest.ssr.ts" + ], + "output": [ + "coverage/ssr/**" + ], + "dependencies": [ + "../internals/vite:ci", + { + "script": "build", + "cascade": false + } + ], + "env": { + "NODE_ENV": "production" + } + }, "lint": { "dependencies": [ "lint:eslint", diff --git a/projects/code/src/bundle.ts b/projects/code/src/bundle.ts index 979196b117..10c5627428 100644 --- a/projects/code/src/bundle.ts +++ b/projects/code/src/bundle.ts @@ -17,5 +17,7 @@ import '@nvidia-elements/code/codeblock/languages/typescript.js'; import '@nvidia-elements/code/codeblock/languages/xml.js'; import '@nvidia-elements/code/codeblock/languages/yaml.js'; import '@nvidia-elements/code/codeblock/define.js'; +import '@nvidia-elements/code/iframe/define.js'; export * from '@nvidia-elements/code/codeblock'; +export * from '@nvidia-elements/code/iframe'; diff --git a/projects/code/src/iframe/define.ts b/projects/code/src/iframe/define.ts new file mode 100644 index 0000000000..06bd8e433e --- /dev/null +++ b/projects/code/src/iframe/define.ts @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { define } from '@nvidia-elements/core/internal'; +import { Iframe } from './iframe.js'; + +define(Iframe); + +declare global { + interface HTMLElementTagNameMap { + 'nve-iframe': Iframe; + } +} diff --git a/projects/code/src/iframe/iframe.css b/projects/code/src/iframe/iframe.css new file mode 100644 index 0000000000..24cb571e31 --- /dev/null +++ b/projects/code/src/iframe/iframe.css @@ -0,0 +1,18 @@ +/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */ +/* SPDX-License-Identifier: Apache-2.0 */ + +:host { + --width: var(--_width, fit-content); + --height: var(--_height, fit-content); + --border: none; + display: block; + width: fit-content; + height: fit-content; + border: var(--border); +} + +iframe { + width: var(--width); + height: var(--height); + border: 0; +} diff --git a/projects/code/src/iframe/iframe.examples.ts b/projects/code/src/iframe/iframe.examples.ts new file mode 100644 index 0000000000..d8fc98b411 --- /dev/null +++ b/projects/code/src/iframe/iframe.examples.ts @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import '@nvidia-elements/code/iframe/define.js'; + +export default { + title: 'Code/Iframe', + component: 'nve-iframe' +}; + +/** + * @summary Supplies the iframe with Elements themes, utilities, fonts, and component registrations through its head template. Use this structure because iframe documents do not inherit resources from the parent document. + */ +export const Default = { + render: () => html` + + + + + ` +}; + +/** + * @summary Synchronizes the iframe height with expandable content so the host layout avoids empty space or internal scrollbars. Use for previews whose intrinsic height changes after interaction. + * @tags test-case + */ +export const DynamicHeight = { + render: () => html` + + + + + ` +}; + +/** + * @summary Overrides intrinsic iframe dimensions with `--width` and `--height` to create a stable preview viewport. Use when you need to inspect embedded content at a fixed size regardless of its rendered bounds. + * @tags test-case + */ +export const FixedSize = { + render: () => html` + + + + ` +}; + +/** + * @summary The iframe browsing-context boundary clips popovers at its viewport and prevents them from escaping. Keep overlays inside the frame, or render them outside the iframe when they must overlap surrounding content. + * @tags test-case + */ +export const OverflowClip = { + render: () => html` + + + + + ` +}; + +/** + * @summary Regenerates the iframe document when its source template changes. Use for live previews or generated output that must stay synchronized with edits made in the parent document. + * @tags test-case + */ +export const DynamicallyUpdatedContent = { + render: () => html` + + + + + ` +}; diff --git a/projects/code/src/iframe/iframe.test.axe.ts b/projects/code/src/iframe/iframe.test.axe.ts new file mode 100644 index 0000000000..a7dc2306cd --- /dev/null +++ b/projects/code/src/iframe/iframe.test.axe.ts @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { createFixture, elementIsStable, removeFixture } from '@internals/testing'; +import { runAxe } from '@internals/testing/axe'; +import { Iframe } from '@nvidia-elements/code/iframe'; +import '@nvidia-elements/code/iframe/define.js'; + +describe(Iframe.metadata.tag, () => { + let fixture: HTMLElement; + let element: Iframe; + + beforeEach(async () => { + fixture = await createFixture(html``); + element = fixture.querySelector`; + } + + connectedCallback() { + super.connectedCallback(); + globalThis.window.addEventListener('message', this.#handleResizeMessage); + if (this.hasUpdated) { + this.#setupTemplateUpdates(); + this.#setupTemplateHeadUpdates(); + this.#setupThemeUpdates(); + this.#updateSource(); + } + } + + firstUpdated(props: PropertyValues) { + super.firstUpdated(props); + this.#setupTemplateUpdates(); + this.#setupTemplateHeadUpdates(); + this.#setupThemeUpdates(); + this.#updateSource(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.#templateObserver?.disconnect(); + this.#headTemplateObserver?.disconnect(); + this.#themeObserver?.disconnect(); + this.#templateObserver = undefined; + this.#headTemplateObserver = undefined; + this.#themeObserver = undefined; + globalThis.window.removeEventListener('message', this.#handleResizeMessage); + } + + // and can't be slotted https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/template#usage_notes + #updateSource() { + const iframe = this.#iframe; + if (iframe === null) { + return; + } + + const theme = this.#themeHost?.getAttribute('nve-theme') ?? ''; + iframe.srcdoc = ` + + + + + ${this.#headTemplate?.innerHTML ?? ''} + + + ${this.#template?.innerHTML ?? ''} + ${iframeScript} + + + `; + } + + #setupTemplateUpdates() { + if (this.#template && !this.#templateObserver) { + this.#templateObserver = new MutationObserver(() => this.#updateSource()); + this.#templateObserver.observe(this.#template.content, { childList: true, subtree: true, characterData: true }); + } + } + + #setupTemplateHeadUpdates() { + if (this.#headTemplate && !this.#headTemplateObserver) { + this.#headTemplateObserver = new MutationObserver(() => this.#updateSource()); + this.#headTemplateObserver.observe(this.#headTemplate.content, { + childList: true, + subtree: true, + characterData: true + }); + } + } + + #setupThemeUpdates() { + const themeHost = this.#themeHost; + if (themeHost && !this.#themeObserver) { + this.#themeObserver = new MutationObserver(() => this.#syncTheme()); + this.#themeObserver.observe(themeHost, { attributeFilter: ['nve-theme'] }); + } + } + + #syncTheme = () => { + const theme = this.#themeHost?.getAttribute('nve-theme'); + const contentWindow = this.#iframe?.contentWindow; + if (theme === null || theme === undefined || contentWindow === null || contentWindow === undefined) { + return; + } + + const message: ThemeMessage = { theme }; + // Sandboxed srcdoc documents have opaque origins, so authenticate messages by their window reference. + contentWindow.postMessage(JSON.stringify(message), '*'); + }; + + #handleResizeMessage = (event: MessageEvent) => { + const iframe = this.#iframe; + if (iframe === null || event.source !== iframe.contentWindow || typeof event.data !== 'string') { + return; + } + + let message: unknown; + try { + message = JSON.parse(event.data); + } catch { + return; + } + + if (!isResizeMessage(message)) { + return; + } + + this.style.setProperty('--_width', `${message.width}px`); + this.style.setProperty('--_height', `${message.height}px`); + }; +} diff --git a/projects/code/src/iframe/index.ts b/projects/code/src/iframe/index.ts new file mode 100644 index 0000000000..b369ae3187 --- /dev/null +++ b/projects/code/src/iframe/index.ts @@ -0,0 +1,4 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export * from './iframe.js'; diff --git a/projects/code/src/index.test.lighthouse.ts b/projects/code/src/index.test.lighthouse.ts index 76f949110e..8bc1c2cd89 100644 --- a/projects/code/src/index.test.lighthouse.ts +++ b/projects/code/src/index.test.lighthouse.ts @@ -21,10 +21,11 @@ describe('lighthouse report', () => { import('@nvidia-elements/code/codeblock/languages/xml.js'); import('@nvidia-elements/code/codeblock/languages/yaml.js'); import('@nvidia-elements/code/codeblock/define.js'); + import('@nvidia-elements/code/iframe/define.js'); `); - expect(report.payload.javascript.kb).toBeLessThan(45); + expect(report.payload.javascript.kb).toBeLessThan(46); expect(report.payload.javascript.requests['define.js']!.kb).toBeLessThan(10.6); expect(report.payload.javascript.requests['core.js']!.kb).toBeLessThan(9); expect(report.payload.javascript.requests['bash.js']!.kb).toBeLessThan(3); diff --git a/projects/code/vitest.ssr.ts b/projects/code/vitest.ssr.ts new file mode 100644 index 0000000000..260dee001d --- /dev/null +++ b/projects/code/vitest.ssr.ts @@ -0,0 +1,15 @@ +import { resolve } from 'path'; +import { mergeConfig } from 'vitest/config'; +import { libraryLitSSRTestConfig } from '@internals/vite/configs/ssr.js'; + +export default mergeConfig(libraryLitSSRTestConfig, { + resolve: { + alias: { '@nvidia-elements/code': resolve(import.meta.dirname, './dist') } + }, + test: { + include: ['./src/**/*.test.ssr.ts'], + outputFile: { + junit: './coverage/ssr/junit.xml' + } + } +}); diff --git a/projects/internals/eslint/src/local/example-approved-domains.js b/projects/internals/eslint/src/local/example-approved-domains.js index ccf45f3839..f56b20d437 100644 --- a/projects/internals/eslint/src/local/example-approved-domains.js +++ b/projects/internals/eslint/src/local/example-approved-domains.js @@ -1,6 +1,6 @@ import { findHtmlTemplate, getTemplateText, hasTag } from './example-helpers.js'; -const APPROVED_DOMAINS = ['nvidia.com', 'github.com']; +const APPROVED_DOMAINS = ['nvidia.com', 'github.com', 'cdn.jsdelivr.net']; const URL_ATTRIBUTES = ['href', 'src', 'srcset']; const URL_ATTRIBUTE_PATTERN = new RegExp(`\\b(${URL_ATTRIBUTES.join('|')})\\s*=\\s*("([^"]*)"|'([^']*)')`, 'gi'); diff --git a/projects/site/src/_11ty/layouts/common.js b/projects/site/src/_11ty/layouts/common.js index de8027b741..5dc49be905 100644 --- a/projects/site/src/_11ty/layouts/common.js +++ b/projects/site/src/_11ty/layouts/common.js @@ -345,6 +345,7 @@ export const renderDocsNav = data => /* html */ ` Code Codeblock + Iframe diff --git a/projects/site/src/docs/code/iframe.md b/projects/site/src/docs/code/iframe.md new file mode 100644 index 0000000000..f84509528e --- /dev/null +++ b/projects/site/src/docs/code/iframe.md @@ -0,0 +1,40 @@ +--- +{ + title: 'Iframe', + layout: 'docs.11ty.js', + tag: 'nve-iframe' +} +--- + +## Installation + +```typescript +import '@nvidia-elements/code/iframe/define.js'; +``` + +```html + + + + +``` + +## Iframe + +{% example '@nvidia-elements/code/iframe/iframe.examples.json', 'Default' %} + +## Dynamic Height + +{% example '@nvidia-elements/code/iframe/iframe.examples.json', 'DynamicHeight' %} + +## Fixed Size + +{% example '@nvidia-elements/code/iframe/iframe.examples.json', 'FixedSize' %}