diff --git a/package.json b/package.json
index acdc2e1602..73abb3e247 100644
--- a/package.json
+++ b/package.json
@@ -168,10 +168,11 @@
"release:cli",
"release:code",
"release:create",
+ "release:foundations",
"release:themes",
"release:core",
"release:lint",
- "release:labs-forms",
+ "release:forms",
"release:markdown",
"release:media",
"release:monaco",
@@ -193,7 +194,8 @@
"command": "cd projects/core && HUSKY=0 pnpm exec semantic-release",
"dependencies": [
"release:themes",
- "release:labs-forms"
+ "release:foundations",
+ "release:forms"
]
},
"release:cli": {
@@ -208,9 +210,15 @@
"release:core"
]
},
- "release:labs-forms": {
+ "release:forms": {
"command": "cd projects/forms && HUSKY=0 pnpm exec semantic-release"
},
+ "release:foundations": {
+ "command": "cd projects/foundations && HUSKY=0 pnpm exec semantic-release",
+ "dependencies": [
+ "release:themes"
+ ]
+ },
"release:lint": {
"command": "cd projects/lint && HUSKY=0 pnpm exec semantic-release",
"dependencies": [
@@ -235,7 +243,7 @@
"dependencies": [
"release:core",
"release:themes",
- "release:labs-forms"
+ "release:forms"
]
},
"release:pi": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7d2f87b3d9..a054f9ed90 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -806,9 +806,6 @@ importers:
projects/foundations:
dependencies:
- '@nvidia-elements/core':
- specifier: workspace:^
- version: link:../core
'@nvidia-elements/themes':
specifier: workspace:^
version: link:../themes
@@ -828,6 +825,9 @@ importers:
'@internals/vite':
specifier: workspace:*
version: link:../internals/vite
+ '@nvidia-elements/core':
+ specifier: workspace:*
+ version: link:../core
'@nvidia-elements/lint':
specifier: workspace:*
version: link:../lint
@@ -836,7 +836,7 @@ importers:
version: link:../styles
'@vitest/browser':
specifier: 'catalog:'
- version: 5.0.0(vite@8.2.2(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.48.0)(yaml@2.9.0))(vitest@5.0.0)
+ version: 5.0.0(vite@8.2.2(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.31))(terser@5.48.0)(yaml@2.9.0))(vitest@5.0.0)
'@vitest/coverage-istanbul':
specifier: 'catalog:'
version: 5.0.0(vitest@5.0.0)
@@ -860,10 +860,10 @@ importers:
version: 6.0.3
vite:
specifier: 'catalog:'
- version: 8.2.2(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.48.0)(yaml@2.9.0)
+ version: 8.2.2(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.31))(terser@5.48.0)(yaml@2.9.0)
vitest:
specifier: 'catalog:'
- version: 5.0.0(@opentelemetry/api@1.9.1)(@types/node@26.5.0)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-istanbul@5.0.0)(@vitest/ui@5.0.0)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.48.0)(yaml@2.9.0))
+ version: 5.0.0(@opentelemetry/api@1.9.1)(@types/node@26.5.0)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-istanbul@5.0.0)(@vitest/ui@5.0.0)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.31))(terser@5.48.0)(yaml@2.9.0))
projects/internals/design:
devDependencies:
diff --git a/projects/foundations/.visual/foundations.dark.png b/projects/foundations/.visual/foundations.dark.png
deleted file mode 100644
index a278f4de72..0000000000
--- a/projects/foundations/.visual/foundations.dark.png
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:0c131d1b32d2f5f8cad54d5b4988bd4b0cc430c63273cfd40d771d8a679e79d6
-size 2209
diff --git a/projects/foundations/.visual/foundations.png b/projects/foundations/.visual/foundations.png
deleted file mode 100644
index c91a303f65..0000000000
--- a/projects/foundations/.visual/foundations.png
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:4e33171ce14db36de5a2c459a4196565234bdba1fc7ab8781aecdbe02024c10b
-size 2190
diff --git a/projects/foundations/.visual/scroll-box.dark.png b/projects/foundations/.visual/scroll-box.dark.png
new file mode 100644
index 0000000000..ca8f148bf0
--- /dev/null
+++ b/projects/foundations/.visual/scroll-box.dark.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:17957208277f01f6e77e99919838edfbf3b046c84b232d2fadde73625163c64e
+size 2915
diff --git a/projects/foundations/.visual/scroll-box.focus.png b/projects/foundations/.visual/scroll-box.focus.png
new file mode 100644
index 0000000000..62ee39eb1c
--- /dev/null
+++ b/projects/foundations/.visual/scroll-box.focus.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d8e7e80feb4866f0f2e0ae153197742f6e8e70db6821e3107901b1de7c82e181
+size 2674
diff --git a/projects/foundations/.visual/scroll-box.forced-colors.png b/projects/foundations/.visual/scroll-box.forced-colors.png
new file mode 100644
index 0000000000..d643efe860
--- /dev/null
+++ b/projects/foundations/.visual/scroll-box.forced-colors.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:23051ed36bf332ed88aef7031bf872ed005292d6f526a2d322f9addcc558a853
+size 1512
diff --git a/projects/foundations/.visual/scroll-box.png b/projects/foundations/.visual/scroll-box.png
new file mode 100644
index 0000000000..2f0798f066
--- /dev/null
+++ b/projects/foundations/.visual/scroll-box.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:64c441af5e57636dc55c8c24731bf880167bdafb4a87fe1b4d5cde9e93778885
+size 2525
diff --git a/projects/foundations/README.md b/projects/foundations/README.md
index 28183f0fb5..385c847605 100644
--- a/projects/foundations/README.md
+++ b/projects/foundations/README.md
@@ -1,3 +1,50 @@
# @nvidia-elements/foundations
-A slotted container component for NVIDIA Elements.
+NVIDIA UI SDK and agent-ready developer toolkit for AI factories, robotics, and autonomous vehicles.
+
+Shared components and utilities for NVIDIA Elements libraries and custom component development.
+
+Elements packages use foundations to share components and behavior without depending directly on `@nvidia-elements/core`. Advanced users can also use its utility components and internal helpers when building their own Web Components.
+
+- [Documentation](https://NVIDIA.github.io/elements/)
+- [Changelog](https://NVIDIA.github.io/elements/docs/changelog/)
+- [GitHub Repo](https://github.com/NVIDIA/elements)
+- [npm](https://www.npmjs.com/package/@nvidia-elements/foundations)
+
+## Internal Utilities
+
+Shared helpers support element registration, styling, and other library implementation details. The `/internal` entrypoint provides a semi-public API for Elements packages and advanced component authors but are not API stable:
+
+```javascript
+import { define, useStyles } from '@nvidia-elements/foundations/internal';
+```
+
+## Public Utility Components
+
+Utility components have separate entrypoints for application and library use. The package currently provides:
+
+| Component | Tag | Description |
+| --------- | --- | ----------- |
+| [Scroll Box](https://NVIDIA.github.io/elements/docs/elements/scroll-box/) | `nve-scroll-box` | A scrollable container with optional edge shadows and automatic scrolling. |
+
+## Getting Started
+
+Install the package and its peer dependency:
+
+```shell
+npm install @nvidia-elements/foundations @nvidia-elements/themes
+```
+
+Load the base theme in your application's stylesheet:
+
+```css
+@import '@nvidia-elements/themes/index.css';
+```
+
+## NVIDIA Elements Skill
+
+Install the Elements agent skill with the open [skills](https://www.skills.sh/nvidia/elements/elements) CLI:
+
+```shell
+npx skills add https://github.com/nvidia/elements --skill elements
+```
diff --git a/projects/foundations/eslint.config.js b/projects/foundations/eslint.config.js
index ee96dbce97..a672facae2 100644
--- a/projects/foundations/eslint.config.js
+++ b/projects/foundations/eslint.config.js
@@ -2,4 +2,17 @@ import { elementsRecommended } from '@nvidia-elements/lint/eslint';
import { browserTypescriptConfig, libraryConfig, litConfig, jsonConfig } from '@internals/eslint';
/** @type {import('eslint').Linter.Config[]} */
-export default [...elementsRecommended, ...browserTypescriptConfig, ...libraryConfig, ...litConfig, ...jsonConfig];
+export default [
+ ...elementsRecommended,
+ ...browserTypescriptConfig,
+ ...libraryConfig,
+ ...litConfig,
+ ...jsonConfig,
+ {
+ files: ['src/scroll-box/scroll-box.css'],
+ rules: {
+ // Scroll edge shadows use gradients.
+ '@nvidia-elements/lint/no-custom-background-gradients': 'off'
+ }
+ }
+];
diff --git a/projects/foundations/package.json b/projects/foundations/package.json
index 5b53a86f5f..b9ded5137b 100644
--- a/projects/foundations/package.json
+++ b/projects/foundations/package.json
@@ -1,11 +1,10 @@
{
"name": "@nvidia-elements/foundations",
"version": "0.0.0",
- "private": true,
"release": {
"extends": "../../release.config.js"
},
- "description": "A slotted container component for NVIDIA Elements.",
+ "description": "Shared foundational components and utilities for NVIDIA UI libraries and custom component development.",
"keywords": [
"nvidia",
"web-components",
@@ -52,13 +51,25 @@
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
- "./foundations": {
- "types": "./dist/foundations/index.d.ts",
- "default": "./dist/foundations/index.js"
+ "./scroll-box": {
+ "types": "./dist/scroll-box/index.d.ts",
+ "default": "./dist/scroll-box/index.js"
},
- "./foundations/define.js": {
- "types": "./dist/foundations/define.d.ts",
- "default": "./dist/foundations/define.js"
+ "./scroll-box/index.js": {
+ "types": "./dist/scroll-box/index.d.ts",
+ "default": "./dist/scroll-box/index.js"
+ },
+ "./scroll-box/define.js": {
+ "types": "./dist/scroll-box/define.d.ts",
+ "default": "./dist/scroll-box/define.js"
+ },
+ "./internal": {
+ "types": "./dist/internal/index.d.ts",
+ "default": "./dist/internal/index.js"
+ },
+ "./internal/index.js": {
+ "types": "./dist/internal/index.d.ts",
+ "default": "./dist/internal/index.js"
}
},
"sideEffects": [
@@ -84,7 +95,6 @@
"lit": "catalog:publish"
},
"peerDependencies": {
- "@nvidia-elements/core": "workspace:^",
"@nvidia-elements/themes": "workspace:^"
},
"devDependencies": {
@@ -92,6 +102,7 @@
"@internals/eslint": "workspace:*",
"@internals/testing": "workspace:*",
"@internals/vite": "workspace:*",
+ "@nvidia-elements/core": "workspace:*",
"@nvidia-elements/lint": "workspace:*",
"@nvidia-elements/styles": "workspace:*",
"@vitest/browser": "catalog:",
@@ -111,7 +122,8 @@
"command": "NODE_ENV=development vite build --watch",
"service": true,
"dependencies": [
- "../core:build",
+ "../themes:build",
+ "../styles:build",
"../internals/vite:ci"
]
},
@@ -160,10 +172,6 @@
{
"script": "../themes:build",
"cascade": false
- },
- {
- "script": "../core:build",
- "cascade": false
}
]
},
@@ -191,7 +199,7 @@
"dependencies": [
"../internals/testing:build",
"../internals/vite:ci",
- "../core:build"
+ "../themes:build"
]
},
"test:coverage": {
@@ -213,7 +221,7 @@
"dependencies": [
"../internals/testing:build",
"../internals/vite:ci",
- "../core:build"
+ "../themes:build"
]
},
"test:watch": {
@@ -233,6 +241,7 @@
],
"dependencies": [
"build",
+ "../styles:build",
"../internals/vite:ci"
],
"env": {
@@ -254,6 +263,7 @@
],
"dependencies": [
"../internals/vite:ci",
+ "../styles:build",
{
"script": "build",
"cascade": false
diff --git a/projects/foundations/src/foundations/foundations.css b/projects/foundations/src/foundations/foundations.css
deleted file mode 100644
index 391ce6345f..0000000000
--- a/projects/foundations/src/foundations/foundations.css
+++ /dev/null
@@ -1,6 +0,0 @@
-/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */
-/* SPDX-License-Identifier: Apache-2.0 */
-
-:host {
- display: block;
-}
diff --git a/projects/foundations/src/foundations/foundations.examples.ts b/projects/foundations/src/foundations/foundations.examples.ts
deleted file mode 100644
index c430c4c277..0000000000
--- a/projects/foundations/src/foundations/foundations.examples.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-import { html } from 'lit';
-import '@nvidia-elements/foundations/foundations/define.js';
-
-export default {
- title: 'Elements/Foundations',
- component: 'nve-foundations',
-};
-
-/**
- * @summary Display content inside a slotted container.
- */
-export const Default = {
- render: () => html`
-Container content
- `
-};
diff --git a/projects/foundations/src/foundations/foundations.test.axe.ts b/projects/foundations/src/foundations/foundations.test.axe.ts
deleted file mode 100644
index 0ec941b034..0000000000
--- a/projects/foundations/src/foundations/foundations.test.axe.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-// 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 { Foundations } from '@nvidia-elements/foundations/foundations';
-import '@nvidia-elements/foundations/foundations/define.js';
-
-describe(Foundations.metadata.tag, () => {
- let fixture: HTMLElement;
- let element: Foundations;
-
- beforeEach(async () => {
- fixture = await createFixture(html`
- Container content
- `);
- element = fixture.querySelector(Foundations.metadata.tag);
- await elementIsStable(element);
- });
-
- afterEach(() => {
- removeFixture(fixture);
- });
-
- it('should pass axe check', async () => {
- const results = await runAxe([Foundations.metadata.tag]);
- expect(results.violations.length).toBe(0);
- });
-});
diff --git a/projects/foundations/src/foundations/foundations.test.lighthouse.ts b/projects/foundations/src/foundations/foundations.test.lighthouse.ts
deleted file mode 100644
index b2829c55c1..0000000000
--- a/projects/foundations/src/foundations/foundations.test.lighthouse.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-import { expect, test, describe } from 'vitest';
-import { lighthouseRunner } from '@internals/vite';
-
-describe('foundations lighthouse report', () => {
- test('foundations should meet lighthouse benchmarks', async () => {
- const report = await lighthouseRunner.getReport('nve-foundations', /* html */`
- Container content
-
- `);
-
- expect(report.scores.performance).toBe(100);
- expect(report.scores.accessibility).toBe(100);
- expect(report.scores.bestPractices).toBe(100);
- expect(report.payload.javascript.kb).toBeLessThan(10);
- });
-});
diff --git a/projects/foundations/src/foundations/foundations.test.ssr.ts b/projects/foundations/src/foundations/foundations.test.ssr.ts
deleted file mode 100644
index fa91a6edbd..0000000000
--- a/projects/foundations/src/foundations/foundations.test.ssr.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-// 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 } from 'vitest';
-import { ssrRunner } from '@internals/vite';
-import { Foundations } from '@nvidia-elements/foundations/foundations';
-import '@nvidia-elements/foundations/foundations/define.js';
-
-describe(Foundations.metadata.tag, () => {
- it('should pass baseline ssr check', async () => {
- const result = await ssrRunner.render(
- html`Container content
`
- );
- expect(result.includes('shadowroot="open"')).toBe(true);
- expect(result.includes('nve-foundations')).toBe(true);
- expect(result.includes(' ')).toBe(true);
- expect(result.includes('Container content')).toBe(true);
- });
-});
diff --git a/projects/foundations/src/foundations/foundations.test.ts b/projects/foundations/src/foundations/foundations.test.ts
deleted file mode 100644
index d3f6b36706..0000000000
--- a/projects/foundations/src/foundations/foundations.test.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-// 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 { Foundations } from '@nvidia-elements/foundations/foundations';
-import '@nvidia-elements/foundations/foundations/define.js';
-
-describe(Foundations.metadata.tag, () => {
- let fixture: HTMLElement;
- let element: Foundations;
-
- beforeEach(async () => {
- fixture = await createFixture(html`
- Container content
- `);
- element = fixture.querySelector(Foundations.metadata.tag);
- await elementIsStable(element);
- });
-
- afterEach(() => {
- removeFixture(fixture);
- });
-
- it('should define element', () => {
- expect(customElements.get(Foundations.metadata.tag)).toBeDefined();
- });
-
- it('should display content through the default slot', () => {
- const slot = element.shadowRoot.querySelector('slot');
- expect(slot.assignedElements()).toEqual([element.querySelector('p')]);
- });
-
- it('should display content added after rendering', async () => {
- const content = document.createElement('p');
- content.textContent = 'Additional content';
- element.append(content);
- await elementIsStable(element);
-
- const slot = element.shadowRoot.querySelector('slot');
- expect(slot.assignedElements()).toContain(content);
- });
-});
diff --git a/projects/foundations/src/foundations/foundations.test.visual.ts b/projects/foundations/src/foundations/foundations.test.visual.ts
deleted file mode 100644
index 170f7e4c74..0000000000
--- a/projects/foundations/src/foundations/foundations.test.visual.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-import { expect, test, describe } from 'vitest';
-import { visualRunner } from '@internals/vite';
-
-describe('foundations visual', () => {
- test('foundations should match visual baseline', async () => {
- const report = await visualRunner.render('foundations', template());
- expect(report.maxDiffPercentage).toBeLessThan(1);
- });
-
- test('foundations should match visual baseline dark theme', async () => {
- const report = await visualRunner.render('foundations.dark', template('dark'));
- expect(report.maxDiffPercentage).toBeLessThan(1);
- });
-});
-
-function template(theme: '' | 'dark' = '') {
- return /* html */ `
-
- Container content
- `;
-}
diff --git a/projects/foundations/src/foundations/foundations.ts b/projects/foundations/src/foundations/foundations.ts
deleted file mode 100644
index 18d5c52144..0000000000
--- a/projects/foundations/src/foundations/foundations.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-import { html, LitElement } from 'lit';
-import { useStyles } from '@nvidia-elements/core/internal';
-import styles from './foundations.css?inline';
-
-/**
- * @element nve-foundations
- * @description A slotted container component for NVIDIA Elements.
- * @documentation https://nvidia.github.io/elements/docs/foundations/component/
- * @since 0.0.0
- * @entrypoint \@nvidia-elements/foundations/foundations
- * @slot - Content to display inside the container.
- */
-export class Foundations extends LitElement {
- static styles = useStyles([styles]);
-
- static readonly metadata = {
- tag: 'nve-foundations',
- version: '0.0.0'
- };
-
- render() {
- return html`
-
-
-
- `;
- }
-}
diff --git a/projects/foundations/src/internal/define.test.ts b/projects/foundations/src/internal/define.test.ts
new file mode 100644
index 0000000000..5582fc4d68
--- /dev/null
+++ b/projects/foundations/src/internal/define.test.ts
@@ -0,0 +1,33 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { describe, expect, it } from 'vitest';
+import { define } from '@nvidia-elements/foundations/internal';
+
+describe('define', () => {
+ it('should register an element using its metadata without initializing core state', () => {
+ class TestElement extends HTMLElement {
+ static readonly metadata = { tag: 'foundations-define-test', version: '0.0.0' };
+ }
+
+ const state = Object.getOwnPropertyDescriptor(globalThis, 'NVE_ELEMENTS');
+ define(TestElement);
+ expect(customElements.get(TestElement.metadata.tag)).toBe(TestElement);
+ expect(Object.getOwnPropertyDescriptor(globalThis, 'NVE_ELEMENTS')).toEqual(state);
+ });
+
+ it('should preserve an existing registration when imported more than once', () => {
+ class TestElement extends HTMLElement {
+ static readonly metadata = { tag: 'foundations-duplicate-test', version: '0.0.0' };
+ }
+
+ class DuplicateElement extends HTMLElement {
+ static readonly metadata = TestElement.metadata;
+ }
+
+ define(TestElement);
+ expect(() => define(TestElement)).not.toThrow();
+ expect(() => define(DuplicateElement)).not.toThrow();
+ expect(customElements.get(TestElement.metadata.tag)).toBe(TestElement);
+ });
+});
diff --git a/projects/foundations/src/internal/define.ts b/projects/foundations/src/internal/define.ts
new file mode 100644
index 0000000000..b06bbd47ba
--- /dev/null
+++ b/projects/foundations/src/internal/define.ts
@@ -0,0 +1,13 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+interface ElementDefinition extends CustomElementConstructor {
+ metadata: { tag: string; version: string };
+}
+
+/** Defines an element with the global custom element registry. */
+export function define(element: ElementDefinition) {
+ if (!customElements.get(element.metadata.tag)) {
+ customElements.define(element.metadata.tag, element);
+ }
+}
diff --git a/projects/foundations/src/internal/index.ts b/projects/foundations/src/internal/index.ts
new file mode 100644
index 0000000000..83a1c6b4fe
--- /dev/null
+++ b/projects/foundations/src/internal/index.ts
@@ -0,0 +1,7 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+/** @internal Common utilities for foundational components. */
+export * from './define.js';
+export * from './styles/index.js';
+export * from './utils/a11y.js';
diff --git a/projects/foundations/src/internal/styles/base.css b/projects/foundations/src/internal/styles/base.css
new file mode 100644
index 0000000000..0c061508d8
--- /dev/null
+++ b/projects/foundations/src/internal/styles/base.css
@@ -0,0 +1,63 @@
+/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */
+/* SPDX-License-Identifier: Apache-2.0 */
+
+/**
+ * https://web.dev/custom-elements-best-practices/
+ * Base CSS for all custom elements.
+ */
+
+:host {
+ all: initial;
+ display: block;
+ visibility: inherit;
+ box-sizing: border-box;
+ font-size: inherit;
+ pointer-events: inherit;
+ contain: layout; /* https://developer.mozilla.org/en-US/docs/Web/CSS/contain */
+ font-family: var(--nve-ref-font-family);
+ outline: var(--nve-debug-element-outline, initial);
+ color-scheme: var(--nve-sys-color-scheme);
+}
+
+*,
+*::before,
+*::after {
+ box-sizing: inherit;
+}
+
+/* https://developers.google.com/web/fundamentals/web-components/best-practices#add-a-:host-display-style-that-respects-the-hidden-attribute */
+[hidden],
+:host([hidden]:not([nve-popover])) {
+ display: none !important;
+}
+
+slot {
+ display: contents;
+}
+
+/* https://coryrylan.com/blog/dont-override-css-outline-focus-styles */
+:host(:focus-within) [focus-within],
+:host(:focus) [focusable],
+:host([tabindex='0']:focus) [focusable] {
+ outline: Highlight solid 2px !important;
+ outline: 5px auto -webkit-focus-ring-color !important;
+}
+
+:host([nofocus]) [focus-within] {
+ outline: 0 !important;
+}
+
+:host(:focus-within:not(:focus-visible, [readonly])) [focus-within]:not([focusable='active']),
+:host(:focus:not(:focus-visible)) [focusable]:not([focusable='active']),
+:host([tabindex='0']:focus:not(:focus-visible)) [focusable]:not([focusable='active']) {
+ outline: none !important;
+ outline-offset: 0 !important;
+}
+
+@supports selector(:state(focus)) {
+ :host(:state(focus)) [focusable],
+ :host([tabindex='0']:state(focus)) {
+ outline: Highlight solid 2px !important;
+ outline: 5px auto -webkit-focus-ring-color !important;
+ }
+}
diff --git a/projects/foundations/src/internal/styles/index.ts b/projects/foundations/src/internal/styles/index.ts
new file mode 100644
index 0000000000..ccffa7e0e4
--- /dev/null
+++ b/projects/foundations/src/internal/styles/index.ts
@@ -0,0 +1,10 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { unsafeCSS } from 'lit';
+import base from './base.css?inline';
+import state from './interaction-state.css?inline';
+
+export function useStyles(styles: unknown[]) {
+ return [unsafeCSS(base), unsafeCSS(state), ...styles.map(style => unsafeCSS(style))];
+}
diff --git a/projects/foundations/src/internal/styles/interaction-state.css b/projects/foundations/src/internal/styles/interaction-state.css
new file mode 100644
index 0000000000..dabf04d331
--- /dev/null
+++ b/projects/foundations/src/internal/styles/interaction-state.css
@@ -0,0 +1,46 @@
+/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */
+/* SPDX-License-Identifier: Apache-2.0 */
+
+[interaction-state] {
+ background-image: var(
+ --background-image,
+ linear-gradient(
+ color-mix(
+ in oklab,
+ var(--nve-sys-interaction-state-base) 100%,
+ var(--nve-sys-interaction-state-mix) var(--nve-sys-interaction-state-ratio)
+ )
+ 0 0
+ )
+ ) !important;
+}
+
+:host(:hover) [interaction-state],
+:host(:state(hover)) [interaction-state] {
+ --nve-sys-interaction-state-ratio: var(--nve-sys-interaction-state-ratio-hover);
+}
+
+:host(:active) [interaction-state],
+:host(:state(active)) [interaction-state] {
+ --nve-sys-interaction-state-ratio: var(--nve-sys-interaction-state-ratio-active);
+}
+
+:host([selected]) [interaction-state],
+:host(:state(selected)) [interaction-state] {
+ --nve-sys-interaction-state-ratio: var(--nve-sys-interaction-state-ratio-selected);
+}
+
+:host([current]) [interaction-state],
+:host(:state(current)) [interaction-state] {
+ --nve-sys-interaction-state-ratio: var(--nve-sys-interaction-state-ratio-selected);
+}
+
+:host([disabled]) [interaction-state],
+:host(:state(disabled)) [interaction-state] {
+ --nve-sys-interaction-state-ratio: var(--nve-sys-interaction-state-ratio-disabled);
+}
+
+:host(:not(:state(anchor))[readonly]) [interaction-state],
+:host(:not(:state(anchor)):state(readonly)) [interaction-state] {
+ --nve-sys-interaction-state-ratio: 0;
+}
diff --git a/projects/foundations/src/internal/utils/a11y.test.ts b/projects/foundations/src/internal/utils/a11y.test.ts
new file mode 100644
index 0000000000..eb49ace16d
--- /dev/null
+++ b/projects/foundations/src/internal/utils/a11y.test.ts
@@ -0,0 +1,41 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { html, LitElement } from 'lit';
+import { customElement } from 'lit/decorators/custom-element.js';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { createFixture, removeFixture } from '@internals/testing';
+import { attachInternals } from './a11y.js';
+
+@customElement('element-internals-test-element')
+class ElementInternalsTestElement extends LitElement {
+ declare _internals: ElementInternals;
+
+ connectedCallback() {
+ super.connectedCallback();
+ attachInternals(this);
+ }
+}
+
+describe('attachInternals', () => {
+ let element: ElementInternalsTestElement;
+ let fixture: HTMLElement;
+
+ beforeEach(async () => {
+ fixture = await createFixture(html` `);
+ element = fixture.querySelector('element-internals-test-element') as ElementInternalsTestElement;
+ });
+
+ afterEach(() => {
+ removeFixture(fixture);
+ });
+
+ it('should attach an instance of element internals to a custom element', async () => {
+ expect(element._internals).toBeDefined();
+ });
+
+ it('should prevent a element internals from being called multiple times', async () => {
+ attachInternals(element); // should not throw if called multiple times
+ expect(element._internals).toBeDefined();
+ });
+});
diff --git a/projects/foundations/src/internal/utils/a11y.ts b/projects/foundations/src/internal/utils/a11y.ts
new file mode 100644
index 0000000000..b5765e2420
--- /dev/null
+++ b/projects/foundations/src/internal/utils/a11y.ts
@@ -0,0 +1,5 @@
+export function attachInternals(element: HTMLElement & { _internals?: ElementInternals }) {
+ if (element._internals === undefined) {
+ element._internals = element.attachInternals();
+ }
+}
diff --git a/projects/foundations/src/foundations/define.ts b/projects/foundations/src/scroll-box/define.ts
similarity index 51%
rename from projects/foundations/src/foundations/define.ts
rename to projects/foundations/src/scroll-box/define.ts
index 3206c6c0fc..6aae10b3db 100644
--- a/projects/foundations/src/foundations/define.ts
+++ b/projects/foundations/src/scroll-box/define.ts
@@ -1,13 +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 { Foundations } from '@nvidia-elements/foundations/foundations';
+import { define } from '@nvidia-elements/foundations/internal';
+import { ScrollBox } from '@nvidia-elements/foundations/scroll-box';
-define(Foundations);
+define(ScrollBox);
declare global {
interface HTMLElementTagNameMap {
- 'nve-foundations': Foundations;
+ 'nve-scroll-box': ScrollBox;
}
}
diff --git a/projects/foundations/src/foundations/index.ts b/projects/foundations/src/scroll-box/index.ts
similarity index 80%
rename from projects/foundations/src/foundations/index.ts
rename to projects/foundations/src/scroll-box/index.ts
index 56c8572e41..8e6b1371e7 100644
--- a/projects/foundations/src/foundations/index.ts
+++ b/projects/foundations/src/scroll-box/index.ts
@@ -1,4 +1,4 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
-export * from './foundations.js';
+export * from './scroll-box.js';
diff --git a/projects/foundations/src/scroll-box/scroll-box.css b/projects/foundations/src/scroll-box/scroll-box.css
new file mode 100644
index 0000000000..b276579936
--- /dev/null
+++ b/projects/foundations/src/scroll-box/scroll-box.css
@@ -0,0 +1,141 @@
+/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */
+/* SPDX-License-Identifier: Apache-2.0 */
+
+:host {
+ --scrollbar-color: var(--nve-sys-scrollbar-thumb-color) var(--nve-sys-scrollbar-track-color);
+ --scrollbar-width: var(--nve-sys-scrollbar-width);
+ --shadow-color: transparent;
+ --shadow-size: var(--nve-ref-size-400);
+ position: relative;
+ display: block;
+ min-inline-size: 0;
+ min-block-size: 0;
+ writing-mode: inherit;
+ overflow: auto;
+ container-type: scroll-state;
+ container-name: nve-scroll-box;
+ scrollbar-color: var(--scrollbar-color);
+ scrollbar-width: var(--scrollbar-width);
+}
+
+:host([orientation='horizontal']) {
+ overflow-y: hidden;
+}
+
+:host([orientation='vertical']) {
+ overflow-x: hidden;
+}
+
+:host(:focus-visible) {
+ outline: var(--nve-ref-outline);
+}
+
+.content {
+ display: flow-root;
+}
+
+.shadow-layer {
+ position: absolute;
+ inset-inline-start: 0;
+ inset-block-start: 0;
+ pointer-events: none;
+ z-index: 1;
+}
+
+.shadows {
+ position: sticky;
+ inset: 0;
+}
+
+.edge {
+ --_contact-color: light-dark(var(--nve-ref-color-alpha-black-700), var(--nve-ref-color-alpha-black-1100));
+ --_ambient-color: light-dark(var(--nve-ref-color-alpha-black-600), var(--nve-ref-color-alpha-black-900));
+ position: absolute;
+ opacity: 0;
+ overflow: clip;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .edge {
+ transition: opacity var(--nve-ref-animation-duration-150) var(--nve-ref-animation-easing-100);
+ }
+}
+
+@container nve-scroll-box scroll-state(scrollable: top) {
+ .top {
+ opacity: var(--_shadow-y);
+ }
+}
+
+@container nve-scroll-box scroll-state(scrollable: right) {
+ .right {
+ opacity: var(--_shadow-x);
+ }
+}
+
+@container nve-scroll-box scroll-state(scrollable: bottom) {
+ .bottom {
+ opacity: var(--_shadow-y);
+ }
+}
+
+@container nve-scroll-box scroll-state(scrollable: left) {
+ .left {
+ opacity: var(--_shadow-x);
+ }
+}
+
+/* Following can catch up before scroll-state queries update after content grows. */
+:host(:state(following)) .bottom {
+ opacity: 0;
+ transition: none;
+}
+
+/* Pair a narrow contact shadow with a softer falloff into the content. */
+.top,
+.bottom {
+ left: 0;
+ right: 0;
+ height: var(--shadow-size);
+ max-height: 100%;
+ background: linear-gradient(to bottom, var(--shadow-color), transparent);
+ box-shadow:
+ inset 0 var(--nve-ref-size-100) var(--nve-ref-size-100) calc(-1 * var(--nve-ref-size-100)) var(--_contact-color),
+ inset 0 var(--shadow-size) var(--shadow-size) calc(-1 * var(--shadow-size)) var(--_ambient-color);
+}
+
+.left,
+.right {
+ top: 0;
+ bottom: 0;
+ width: var(--shadow-size);
+ max-width: 100%;
+ background: linear-gradient(to right, var(--shadow-color), transparent);
+ box-shadow:
+ inset var(--nve-ref-size-100) 0 var(--nve-ref-size-100) calc(-1 * var(--nve-ref-size-100)) var(--_contact-color),
+ inset var(--shadow-size) 0 var(--shadow-size) calc(-1 * var(--shadow-size)) var(--_ambient-color);
+}
+
+.top {
+ top: 0;
+}
+
+.right {
+ right: 0;
+ transform: rotate(180deg);
+}
+
+.bottom {
+ bottom: 0;
+ transform: rotate(180deg);
+}
+
+.left {
+ left: 0;
+}
+
+@media (forced-colors: active) {
+ .shadow-layer {
+ display: none;
+ }
+}
diff --git a/projects/foundations/src/scroll-box/scroll-box.examples.ts b/projects/foundations/src/scroll-box/scroll-box.examples.ts
new file mode 100644
index 0000000000..3b5fac86d2
--- /dev/null
+++ b/projects/foundations/src/scroll-box/scroll-box.examples.ts
@@ -0,0 +1,121 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { html } from 'lit';
+import '@nvidia-elements/core/button/define.js';
+import '@nvidia-elements/foundations/scroll-box/define.js';
+
+export default {
+ title: 'Elements/Scroll Box',
+ component: 'nve-scroll-box'
+};
+
+/**
+ * @summary Default scrolling behavior. Use for small content or when you expect the content to fit within the viewport.
+ */
+export const Default = {
+ render: () => html`
+
+
+
Training activity and resource allocation
+
Review training jobs across the cluster and inspect resource allocation for each workload.
+
The panel keeps its size while the application supplies the content and its layout.
+
Scroll in either direction to explore the full activity report and reach the content beyond the viewport.
+
+
+ `
+};
+
+/**
+ * @summary Both-axis scrolling keeps large content reachable within a constrained area. Use for wide diagrams or detailed content in a panel.
+ * @tags test-case
+ */
+export const Axes = {
+ render: () => html`
+
+
+
Training activity and resource allocation
+
Review training jobs across the cluster and inspect resource allocation for each workload.
+
The panel keeps its size while the application supplies the content and its layout.
+
Scroll in either direction to explore the full activity report and reach the content beyond the viewport.
+
+
+ `
+};
+
+/**
+ * @summary Vertical scrolling keeps long activity lists within a fixed-height panel. Use edge shadows to indicate additional content above or below the viewport.
+ * @tags test-case
+ */
+export const Vertical = {
+ render: () => html`
+
+
+ ${Array.from({ length: 12 }, (_, index) => html`
Training job ${index + 1}
`)}
+
+
+ `
+};
+
+/** @summary Automatic scrolling follows new messages while allowing readers to review earlier content. Use for chat responses or logs; return to the latest message to resume following. */
+export const Follow = {
+ render: () => html`
+
+
+
+ ${Array.from({ length: 12 }, (_, index) => html`
Conversation message ${index + 1}
`)}
+
+
+
Add message
+
+
+ `
+};
+
+/**
+ * @summary Horizontal scrolling keeps wide content accessible on narrow screens. Arrange children in a light DOM layout and enable edge shadows for overflow cues.
+ * @tags test-case
+ */
+export const Horizontal = {
+ render: () => html`
+
+
+
Dataset preparation
+
Data validation
+
Data augmentation
+
Model configuration
+
Hyperparameter tuning
+
Resource allocation
+
Training execution
+
Checkpoint validation
+
Model evaluation
+
Model optimization
+
Deployment preparation
+
Production monitoring
+
+
+ `
+};
+
+/**
+ * @summary Hidden scrollbars reduce visual weight while edge shadows indicate overflow. Use when an application provides another clear way to discover scrolling.
+ * @tags test-case
+ */
+export const HiddenScrollbar = {
+ render: () => html`
+
+
+ ${Array.from({ length: 12 }, (_, index) => html`
Training job ${index + 1}
`)}
+
+
+ `
+};
diff --git a/projects/foundations/src/scroll-box/scroll-box.test.axe.ts b/projects/foundations/src/scroll-box/scroll-box.test.axe.ts
new file mode 100644
index 0000000000..594cec8533
--- /dev/null
+++ b/projects/foundations/src/scroll-box/scroll-box.test.axe.ts
@@ -0,0 +1,47 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { html } from 'lit';
+import { afterEach, describe, expect, it } from 'vitest';
+import { createFixture, elementIsStable, removeFixture } from '@internals/testing';
+import { runAxe } from '@internals/testing/axe';
+import { ScrollBox } from '@nvidia-elements/foundations/scroll-box';
+import '@nvidia-elements/foundations/scroll-box/define.js';
+
+describe(ScrollBox.metadata.tag, () => {
+ let fixture: HTMLElement;
+
+ afterEach(() => removeFixture(fixture));
+
+ it('should pass axe with native content without a landmark role', async () => {
+ fixture = await createFixture(html`
+
+ Details
+
+ `);
+ await elementIsStable(fixture.querySelector(ScrollBox.metadata.tag));
+ await new Promise(requestAnimationFrame);
+ const results = await runAxe([ScrollBox.metadata.tag]);
+ expect(results.violations).toHaveLength(0);
+ });
+
+ it.each(['horizontal', 'vertical', 'both'] as const)(
+ 'should pass axe for %s scrolling with and without shadows',
+ async orientation => {
+ fixture = await createFixture(html`
+ ${[false, true].map(
+ shadows => html`
+
+ Details
+
+ `
+ )}
+ `);
+ await Promise.all(Array.from(fixture.querySelectorAll(ScrollBox.metadata.tag), elementIsStable));
+ await new Promise(requestAnimationFrame);
+ const results = await runAxe([ScrollBox.metadata.tag]);
+ expect(results.violations).toHaveLength(0);
+ }
+ );
+});
diff --git a/projects/foundations/src/scroll-box/scroll-box.test.lighthouse.ts b/projects/foundations/src/scroll-box/scroll-box.test.lighthouse.ts
new file mode 100644
index 0000000000..3d3269d658
--- /dev/null
+++ b/projects/foundations/src/scroll-box/scroll-box.test.lighthouse.ts
@@ -0,0 +1,22 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { describe, expect, test } from 'vitest';
+import { lighthouseRunner } from '@internals/vite';
+
+describe('scroll box lighthouse report', () => {
+ test('scroll box should meet lighthouse benchmarks', async () => {
+ const report = await lighthouseRunner.getReport('nve-scroll-box', /* html */ `
+
+ Activity content
+
+
+ `);
+ expect(report.scores.performance).toBe(100);
+ expect(report.scores.accessibility).toBe(100);
+ expect(report.scores.bestPractices).toBe(100);
+ expect(report.payload.javascript.kb).toBeLessThan(15);
+ });
+});
diff --git a/projects/foundations/src/scroll-box/scroll-box.test.ssr.ts b/projects/foundations/src/scroll-box/scroll-box.test.ssr.ts
new file mode 100644
index 0000000000..821ee67333
--- /dev/null
+++ b/projects/foundations/src/scroll-box/scroll-box.test.ssr.ts
@@ -0,0 +1,37 @@
+// 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 } from 'vitest';
+import { ssrRunner } from '@internals/vite';
+import { ScrollBox } from '@nvidia-elements/foundations/scroll-box';
+import '@nvidia-elements/foundations/scroll-box/define.js';
+
+describe(ScrollBox.metadata.tag, () => {
+ it.each(['off', 'follow', 'always'] as const)(
+ 'should render auto-scroll="%s" without browser measurements',
+ async mode => {
+ const result = await ssrRunner.render(html`
+
+ Conversation content
+
+ `);
+ expect(result).toContain(`auto-scroll="${mode}"`);
+ expect(result).toContain('Conversation content');
+ expect(result).toContain('shadowroot="open"');
+ }
+ );
+
+ it.each([false, true])('should render without browser measurements with scroll shadows %s', async shadows => {
+ const result = await ssrRunner.render(html`
+
+ Activity content
+
+ `);
+ expect(result).toContain('shadowroot="open"');
+ expect(result).toContain('nve-scroll-box');
+ expect(result).toContain('Activity content');
+ expect(result).not.toMatch(/]*\btabindex=/);
+ expect(result.includes('class="shadow-layer"')).toBe(shadows);
+ });
+});
diff --git a/projects/foundations/src/scroll-box/scroll-box.test.ts b/projects/foundations/src/scroll-box/scroll-box.test.ts
new file mode 100644
index 0000000000..a0a8d65cb4
--- /dev/null
+++ b/projects/foundations/src/scroll-box/scroll-box.test.ts
@@ -0,0 +1,647 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { html } from 'lit';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { createFixture, elementIsStable, removeFixture, untilEvent } from '@internals/testing';
+import { ScrollBox } from '@nvidia-elements/foundations/scroll-box';
+import '@nvidia-elements/foundations/scroll-box/define.js';
+
+describe(ScrollBox.metadata.tag, () => {
+ let fixture: HTMLElement;
+ let element: ScrollBox;
+ let content: HTMLElement;
+
+ beforeEach(async () => {
+ fixture = await createFixture(html`
+
+
+
+ `);
+ element = fixture.querySelector(ScrollBox.metadata.tag);
+ content = element.querySelector('div');
+ await elementIsStable(element);
+ await vi.waitFor(() => expect(element.tabIndex).toBe(0));
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ removeFixture(fixture);
+ });
+
+ async function enableShadows() {
+ element.scrollShadow = true;
+ await elementIsStable(element);
+ await vi.waitFor(() => expect(element.shadowRoot.querySelector('.shadow-layer').hidden).toBe(false));
+ }
+
+ function visibleEdges(scrollBox = element) {
+ return Array.from(scrollBox.shadowRoot.querySelectorAll('.edge'))
+ .filter(edge => getComputedStyle(edge).opacity === '1')
+ .map(edge => edge.classList.item(1))
+ .sort();
+ }
+
+ async function measureContent(scrollBox = element) {
+ await elementIsStable(scrollBox);
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
+ }
+
+ function expectBottom(scrollBox = element) {
+ expect(scrollBox.scrollTop).toBe(scrollBox.scrollHeight - scrollBox.clientHeight);
+ }
+
+ it('should define the element with both-axis scrolling and no shadows by default', () => {
+ expect(customElements.get(ScrollBox.metadata.tag)).toBe(ScrollBox);
+ expect(element.orientation).toBe('both');
+ expect(element.getAttribute('orientation')).toBe('both');
+ expect(element.scrollShadow).toBe(false);
+ expect(element.autoScroll).toBe('off');
+ expect(element.shadowRoot.querySelector('.shadow-layer')).toBeNull();
+ expect(getComputedStyle(element).overflow).toBe('auto');
+ });
+
+ it.each(['horizontal', 'vertical'] as const)(
+ 'should reflect %s orientation and clip the other axis',
+ async orientation => {
+ element.orientation = orientation;
+ await elementIsStable(element);
+ expect(element.getAttribute('orientation')).toBe(orientation);
+ const style = getComputedStyle(element);
+ expect(style.overflowX).toBe(orientation === 'horizontal' ? 'auto' : 'hidden');
+ expect(style.overflowY).toBe(orientation === 'vertical' ? 'auto' : 'hidden');
+ }
+ );
+
+ it('should preserve native methods, coordinates, and scroll events on the host', async () => {
+ expect(element.scrollTo).toBe(Element.prototype.scrollTo);
+ expect(element.scrollBy).toBe(Element.prototype.scrollBy);
+ const scrolled = untilEvent(element, 'scroll');
+ const ended = untilEvent(element, 'scrollend');
+ element.scrollTo({ left: 80, top: 90 });
+ const event = await scrolled;
+ await ended;
+ expect(event.target).toBe(element);
+ expect(event.bubbles).toBe(false);
+ expect(event).not.toBeInstanceOf(CustomEvent);
+ expect(element.scrollLeft).toBe(80);
+ expect(element.scrollTop).toBe(90);
+ element.scrollBy(20, 30);
+ expect(element.scrollLeft).toBe(100);
+ expect(element.scrollTop).toBe(120);
+ expect(element.scrollWidth).toBe(600);
+ expect(element.scrollHeight).toBe(500);
+ });
+
+ it('should hide scrollbars through native CSS without disabling scrolling', () => {
+ expect(getComputedStyle(element).scrollbarWidth).toBe('none');
+ element.scrollTop = 40;
+ expect(element.scrollTop).toBe(40);
+ });
+
+ it('should preserve the scroll position when auto scrolling is off', async () => {
+ element.scrollTop = 80;
+ content.style.height = '700px';
+ await measureContent();
+ expect(element.scrollTop).toBe(80);
+ });
+
+ it.each(['follow', 'always'] as const)('should start at the bottom with auto-scroll="%s"', async mode => {
+ const scrollBox = document.createElement('nve-scroll-box');
+ scrollBox.setAttribute('auto-scroll', mode);
+ scrollBox.style.cssText = 'width: 200px; height: 150px;';
+ scrollBox.append(content);
+ fixture.append(scrollBox);
+ await elementIsStable(scrollBox);
+ expect(scrollBox.autoScroll).toBe(mode);
+ await vi.waitFor(() => expectBottom(scrollBox));
+ expect(scrollBox.scrollLeft).toBe(0);
+ });
+
+ it.each(['follow', 'always'] as const)('should follow growth and resizing in %s mode', async mode => {
+ element.scrollLeft = 80;
+ element.autoScroll = mode;
+ await vi.waitFor(() => expectBottom());
+ content.style.height = '700px';
+ await vi.waitFor(() => expectBottom());
+ element.style.height = '100px';
+ await vi.waitFor(() => expectBottom());
+ expect(element.scrollLeft).toBe(80);
+ });
+
+ it('should pause following away from the bottom and resume on return', async () => {
+ element.setAttribute('auto-scroll', 'follow');
+ await vi.waitFor(() => expectBottom());
+ element.scrollTop = 80;
+ content.style.height = '700px';
+ await measureContent();
+ expect(element.scrollTop).toBe(80);
+ expect(element.autoScroll).toBe('follow');
+ expect(element.getAttribute('auto-scroll')).toBe('follow');
+ // Return and grow before the native scroll event has arrived.
+ element.scrollTop = element.scrollHeight - element.clientHeight;
+ content.style.height = '900px';
+ await vi.waitFor(() => expectBottom());
+ });
+
+ it('should allow a one-pixel tolerance when following', async () => {
+ element.autoScroll = 'follow';
+ await vi.waitFor(() => expectBottom());
+ element.scrollTop -= 1;
+ content.style.height = '700px';
+ await vi.waitFor(() => expectBottom());
+ });
+
+ it('should return on growth in always mode after manual scrolling', async () => {
+ element.autoScroll = 'always';
+ await vi.waitFor(() => expectBottom());
+ element.scrollTop = 80;
+ await measureContent();
+ expect(element.scrollTop).toBe(80);
+ content.style.height = '700px';
+ await vi.waitFor(() => expectBottom());
+ });
+
+ it('should return on viewport resizing even when the bottom coordinate stays the same', async () => {
+ element.autoScroll = 'always';
+ await vi.waitFor(() => expectBottom());
+ element.scrollTop = 80;
+ await measureContent();
+ expect(element.scrollTop).toBe(80);
+ element.style.width = '180px';
+ await vi.waitFor(() => expectBottom());
+ });
+
+ it('should stop auto scrolling when disabled and return when enabled again', async () => {
+ element.autoScroll = 'follow';
+ await vi.waitFor(() => expectBottom());
+ element.autoScroll = 'off';
+ await elementIsStable(element);
+ content.style.height = '700px';
+ await measureContent();
+ expect(element.scrollTop).toBe(350);
+ element.autoScroll = 'follow';
+ await vi.waitFor(() => expectBottom());
+ });
+
+ it('should follow streaming text and appended content after initially fitting', async () => {
+ content.style.cssText = 'width: 100px; white-space: pre; line-height: 20px;';
+ content.textContent = 'Short';
+ element.autoScroll = 'follow';
+ await measureContent();
+ expect(element.scrollTop).toBe(0);
+ content.firstChild.textContent = 'Response\n'.repeat(30);
+ await vi.waitFor(() => {
+ expect(element.scrollTop).toBeGreaterThan(0);
+ expectBottom();
+ });
+ const message = document.createElement('div');
+ message.style.height = '300px';
+ content.append(message);
+ await vi.waitFor(() => expectBottom());
+ });
+
+ it('should follow content size changes without DOM mutations', async () => {
+ const child = document.createElement('div');
+ child.style.cssText = 'height: 500px; width: 100%;';
+ content.style.width = '100px';
+ content.style.removeProperty('height');
+ content.append(child);
+ element.autoScroll = 'follow';
+ await vi.waitFor(() => expectBottom());
+ const animation = child.animate([{ height: '700px' }, { height: '700px' }], { duration: 1, fill: 'forwards' });
+ await animation.finished;
+ await vi.waitFor(() => {
+ expect(element.scrollHeight).toBe(700);
+ expect(element.shadowRoot.querySelector('[internal-host]').clientHeight).toBe(700);
+ expectBottom();
+ });
+ animation.cancel();
+ });
+
+ it('should follow shrinking and growing content with shadows enabled', async () => {
+ await enableShadows();
+ element.autoScroll = 'follow';
+ await vi.waitFor(() => expectBottom());
+ content.style.cssText = 'width: 50px; height: 50px;';
+ await vi.waitFor(() => {
+ expect(element.scrollHeight).toBe(element.clientHeight);
+ expect(element.scrollTop).toBe(0);
+ });
+ content.style.height = '700px';
+ await vi.waitFor(() => expectBottom());
+ });
+
+ it.each(['follow', 'always'] as const)(
+ 'should keep the bottom shadow hidden while %s content streams in a shadow root',
+ async mode => {
+ const response = document.createElement('div');
+ response.style.height = '500px';
+ content.style.cssText = 'width: 100px;';
+ content.attachShadow({ mode: 'open' }).append(response);
+ await enableShadows();
+ element.autoScroll = mode;
+ await elementIsStable(element);
+ const bottom = element.shadowRoot.querySelector('.bottom');
+ await vi.waitFor(() => {
+ expectBottom();
+ expect(getComputedStyle(bottom).opacity).toBe('0');
+ });
+
+ let maxOpacity = 0;
+ for (let block = 0; block < 12; block++) {
+ response.style.height = `${550 + block * 40}px`;
+ for (let frame = 0; frame < 4; frame++) {
+ await new Promise(requestAnimationFrame);
+ maxOpacity = Math.max(maxOpacity, Number(getComputedStyle(bottom).opacity));
+ }
+ }
+ expectBottom();
+ expect(maxOpacity).toBe(0);
+
+ element.scrollTop = 0;
+ await vi.waitFor(() => expect(getComputedStyle(bottom).opacity).toBe('1'));
+ }
+ );
+
+ it('should restore the bottom shadow when following is disabled', async () => {
+ await enableShadows();
+ element.autoScroll = 'follow';
+ await vi.waitFor(() => expectBottom());
+ element.autoScroll = 'off';
+ await elementIsStable(element);
+ content.style.height = '700px';
+ await vi.waitFor(() => expect(visibleEdges()).toContain('bottom'));
+ expect(element.scrollTop).toBe(350);
+ });
+
+ it('should defer auto scrolling while hidden or restricted to horizontal scrolling', async () => {
+ element.orientation = 'horizontal';
+ element.autoScroll = 'follow';
+ await measureContent();
+ expect(element.scrollTop).toBe(0);
+ element.hidden = true;
+ element.orientation = 'vertical';
+ await measureContent();
+ element.hidden = false;
+ await vi.waitFor(() => expectBottom());
+ });
+
+ it('should keep following during smooth scrolling and stop after scrolling away', async () => {
+ element.style.scrollBehavior = 'smooth';
+ element.autoScroll = 'follow';
+ await vi.waitFor(() => expectBottom());
+ content.style.height = '900px';
+ await vi.waitFor(() => expect(element.scrollTop).toBeGreaterThan(350));
+ content.style.height = '1200px';
+ await vi.waitFor(() => expectBottom());
+ element.scrollTo({ top: 80, behavior: 'instant' });
+ content.style.height = '1400px';
+ await measureContent();
+ expect(element.scrollTop).toBe(80);
+ element.scrollTo({ top: element.scrollHeight, behavior: 'instant' });
+ content.style.height = '1600px';
+ await vi.waitFor(() => expectBottom());
+ });
+
+ it('should follow repeated text growth during smooth scrolling', async () => {
+ content.style.cssText = 'width: 100px; white-space: pre; line-height: 20px;';
+ content.textContent = 'Response\n'.repeat(30);
+ element.style.scrollBehavior = 'smooth';
+ element.autoScroll = 'follow';
+ for (let index = 0; index < 20; index++) {
+ content.firstChild.textContent = 'Response\n'.repeat(31 + index);
+ await new Promise(requestAnimationFrame);
+ }
+ await vi.waitFor(() => expectBottom());
+ });
+
+ it('should pause when scrolling away during an automatic smooth scroll', async () => {
+ element.autoScroll = 'follow';
+ await vi.waitFor(() => expectBottom());
+ element.style.scrollBehavior = 'smooth';
+ content.style.height = '1400px';
+ await vi.waitFor(() => expect(element.scrollTop).toBeGreaterThan(350));
+ const scroll = vi.spyOn(element, 'scrollTo');
+ element.scrollTo({ top: 80, behavior: 'instant' });
+ content.style.height = '1600px';
+ await measureContent();
+ expect(scroll).toHaveBeenCalledTimes(1);
+ expect(element.scrollTop).toBeLessThan(350);
+ });
+
+ it('should cancel an automatic smooth scroll when disabled', async () => {
+ element.style.scrollBehavior = 'smooth';
+ element.autoScroll = 'follow';
+ await vi.waitFor(() => expect(element.scrollTop).toBeGreaterThan(0));
+ element.autoScroll = 'off';
+ await elementIsStable(element);
+ await measureContent();
+ const position = element.scrollTop;
+ content.style.height = '900px';
+ await measureContent();
+ expect(element.scrollTop).toBe(position);
+ expect(element.scrollTop).toBeLessThan(element.scrollHeight - element.clientHeight);
+ });
+
+ it('should retain following through hiding and reconnection', async () => {
+ element.autoScroll = 'follow';
+ await vi.waitFor(() => expectBottom());
+ element.hidden = true;
+ await measureContent();
+ content.style.height = '700px';
+ element.hidden = false;
+ await vi.waitFor(() => expectBottom());
+ element.remove();
+ content.style.height = '900px';
+ fixture.append(element);
+ await vi.waitFor(() => expectBottom());
+ });
+
+ it('should keep a paused follower paused after reconnection', async () => {
+ element.autoScroll = 'follow';
+ await vi.waitFor(() => expectBottom());
+ const scrolled = untilEvent(element, 'scroll');
+ element.scrollTop = 80;
+ await scrolled;
+ element.remove();
+ content.style.height = '700px';
+ fixture.append(element);
+ await measureContent();
+ expect(element.scrollTop).toBeLessThan(element.scrollHeight - element.clientHeight);
+ expect(element.autoScroll).toBe('follow');
+ });
+
+ it('should remove automatic focusability when content fits or is empty', async () => {
+ content.style.cssText = 'width: 100px; height: 100px;';
+ await vi.waitFor(() => expect(element.hasAttribute('tabindex')).toBe(false));
+ content.remove();
+ await vi.waitFor(() => expect(element.scrollHeight).toBe(element.clientHeight));
+ expect(element.hasAttribute('tabindex')).toBe(false);
+ });
+
+ it('should restore automatic focusability when content or the host resizes', async () => {
+ content.style.cssText = 'width: 100px; height: 100px;';
+ await vi.waitFor(() => expect(element.hasAttribute('tabindex')).toBe(false));
+ element.style.height = '50px';
+ await vi.waitFor(() => expect(element.tabIndex).toBe(0));
+ element.style.height = '150px';
+ await vi.waitFor(() => expect(element.hasAttribute('tabindex')).toBe(false));
+ content.style.width = '600px';
+ await vi.waitFor(() => expect(element.tabIndex).toBe(0));
+ });
+
+ it('should consider only enabled axes when assigning automatic focusability', async () => {
+ element.orientation = 'vertical';
+ content.style.height = '100px';
+ await elementIsStable(element);
+ await vi.waitFor(() => expect(element.hasAttribute('tabindex')).toBe(false));
+ element.orientation = 'horizontal';
+ await elementIsStable(element);
+ await vi.waitFor(() => expect(element.tabIndex).toBe(0));
+ });
+
+ it.each(['-1', '0', '3'])('should preserve author tabindex %s through content changes', async tabIndex => {
+ element.setAttribute('tabindex', tabIndex);
+ content.style.cssText = 'width: 50px; height: 50px;';
+ await vi.waitFor(() => expect(element.scrollWidth).toBe(element.clientWidth));
+ await new Promise(requestAnimationFrame);
+ expect(element.getAttribute('tabindex')).toBe(tabIndex);
+ });
+
+ it('should resume automatic focusability after the author removes tabindex', async () => {
+ element.tabIndex = -1;
+ element.removeAttribute('tabindex');
+ await vi.waitFor(() => expect(element.tabIndex).toBe(0));
+ });
+
+ it('should respect tabindex supplied before connection', async () => {
+ const scrollBox = document.createElement('nve-scroll-box');
+ scrollBox.tabIndex = -1;
+ scrollBox.style.cssText = 'width: 50px; height: 50px;';
+ scrollBox.append(content);
+ fixture.append(scrollBox);
+ await elementIsStable(scrollBox);
+ await vi.waitFor(() => expect(scrollBox.scrollHeight).toBe(500));
+ expect(scrollBox.tabIndex).toBe(-1);
+ });
+
+ it('should remove automatic focusability when hidden and restore it when shown', async () => {
+ element.hidden = true;
+ await vi.waitFor(() => expect(element.hasAttribute('tabindex')).toBe(false));
+ element.hidden = false;
+ await vi.waitFor(() => expect(element.tabIndex).toBe(0));
+ });
+
+ it('should show only edges with more content at the start, middle, and end', async () => {
+ await enableShadows();
+ expect(element.hasAttribute('scroll-shadow')).toBe(true);
+ await vi.waitFor(() => expect(visibleEdges()).toEqual(['bottom', 'right']));
+ element.scrollTo(100, 100);
+ await vi.waitFor(() => expect(visibleEdges()).toEqual(['bottom', 'left', 'right', 'top']));
+ element.scrollTo(400, 350);
+ await vi.waitFor(() => expect(visibleEdges()).toEqual(['left', 'top']));
+ element.scrollShadow = false;
+ await elementIsStable(element);
+ expect(element.hasAttribute('scroll-shadow')).toBe(false);
+ expect(element.shadowRoot.querySelector('.shadow-layer')).toBeNull();
+ });
+
+ it.each(['horizontal', 'vertical'] as const)('should show shadows only on the %s axis', async orientation => {
+ element.orientation = orientation;
+ await enableShadows();
+ await vi.waitFor(() => expect(visibleEdges()).toEqual([orientation === 'horizontal' ? 'right' : 'bottom']));
+ element.scrollTo(100, 100);
+ await vi.waitFor(() =>
+ expect(visibleEdges()).toEqual(orientation === 'horizontal' ? ['left', 'right'] : ['bottom', 'top'])
+ );
+ element.orientation = 'both';
+ await elementIsStable(element);
+ await vi.waitFor(() => expect(visibleEdges()).toEqual(['bottom', 'left', 'right', 'top']));
+ });
+
+ it('should hide shadows on axes disabled by author styles', async () => {
+ await enableShadows();
+ element.scrollTo(100, 100);
+ await vi.waitFor(() => expect(visibleEdges()).toEqual(['bottom', 'left', 'right', 'top']));
+ element.style.overflowX = 'hidden';
+ await vi.waitFor(() => expect(visibleEdges()).toEqual(['bottom', 'top']));
+ element.style.overflowY = 'hidden';
+ await vi.waitFor(() => expect(visibleEdges()).toEqual([]));
+ element.style.overflow = 'auto';
+ await vi.waitFor(() => expect(visibleEdges()).toEqual(['bottom', 'left', 'right', 'top']));
+ });
+
+ it('should isolate shadow visibility in nested scroll boxes', async () => {
+ await enableShadows();
+ const nested = document.createElement('nve-scroll-box');
+ nested.scrollShadow = true;
+ nested.style.cssText = 'width: 100px; height: 100px; --scrollbar-width: none;';
+ const child = document.createElement('div');
+ child.style.cssText = 'width: 50px; height: 50px;';
+ nested.append(child);
+ content.append(nested);
+ await elementIsStable(nested);
+ await vi.waitFor(() => expect(visibleEdges()).toEqual(['bottom', 'right']));
+ expect(visibleEdges(nested)).toEqual([]);
+ child.style.height = '300px';
+ await vi.waitFor(() => expect(visibleEdges(nested)).toEqual(['bottom']));
+ nested.scrollTo(0, 200);
+ await vi.waitFor(() => expect(visibleEdges(nested)).toEqual(['top']));
+ expect(visibleEdges()).toEqual(['bottom', 'right']);
+ });
+
+ it('should preserve native negative RTL coordinates and the matching edge cues', async () => {
+ element.dir = 'rtl';
+ await enableShadows();
+ expect(element.scrollLeft).toBe(0);
+ await vi.waitFor(() => expect(visibleEdges()).toEqual(['bottom', 'left']));
+ element.scrollTo(-100, 100);
+ await vi.waitFor(() => expect(visibleEdges()).toEqual(['bottom', 'left', 'right', 'top']));
+ element.scrollTo(-400, 350);
+ await vi.waitFor(() => expect(visibleEdges()).toEqual(['right', 'top']));
+ });
+
+ it.each(['ltr', 'rtl'])('should pin non-interactive shadows with padding and borders in %s', async direction => {
+ element.dir = direction;
+ element.style.padding = '10px';
+ element.style.border = '2px solid';
+ const width = element.scrollWidth;
+ const height = element.scrollHeight;
+ await enableShadows();
+ const layer = element.shadowRoot.querySelector('.shadow-layer');
+ const shadows = layer.querySelector('.shadows');
+ const positions: [number, number][] = [
+ [0, 0],
+ [100, 100],
+ [width - element.clientWidth, height - element.clientHeight]
+ ];
+ for (const [x, y] of positions) {
+ element.scrollTo(direction === 'rtl' ? -x : x, y);
+ const hostBounds = element.getBoundingClientRect();
+ const shadowBounds = shadows.getBoundingClientRect();
+ expect(shadowBounds.left).toBe(hostBounds.left + element.clientLeft);
+ expect(shadowBounds.top).toBe(hostBounds.top + element.clientTop);
+ expect(shadowBounds.width).toBe(element.clientWidth);
+ expect(shadowBounds.height).toBe(element.clientHeight);
+ }
+ expect(element.scrollWidth).toBe(width);
+ expect(element.scrollHeight).toBe(height);
+ expect(layer.getAttribute('aria-hidden')).toBe('true');
+ expect(getComputedStyle(layer).pointerEvents).toBe('none');
+ });
+
+ it('should let content shrink after showing shadows', async () => {
+ await enableShadows();
+ content.style.cssText = 'width: 50px; height: 50px;';
+ await vi.waitFor(() => {
+ expect(visibleEdges()).toEqual([]);
+ expect(element.scrollWidth).toBe(element.clientWidth);
+ expect(element.scrollHeight).toBe(element.clientHeight);
+ expect(element.hasAttribute('tabindex')).toBe(false);
+ });
+ });
+
+ it('should observe asynchronous overflow inside a fixed-size content wrapper', async () => {
+ content.style.cssText = 'width: 100px; height: 100px;';
+ await vi.waitFor(() => expect(element.hasAttribute('tabindex')).toBe(false));
+ const child = document.createElement('div');
+ child.style.height = '500px';
+ content.append(child);
+ await vi.waitFor(() => expect(element.tabIndex).toBe(0));
+ child.style.height = '50px';
+ await vi.waitFor(() => expect(element.hasAttribute('tabindex')).toBe(false));
+ });
+
+ it('should keep two resize targets regardless of descendant count or mutations', async () => {
+ const observe = vi.spyOn(ResizeObserver.prototype, 'observe');
+ const unobserve = vi.spyOn(ResizeObserver.prototype, 'unobserve');
+ const disconnect = vi.spyOn(ResizeObserver.prototype, 'disconnect');
+ const scrollBox = document.createElement('nve-scroll-box');
+ scrollBox.style.cssText = 'width: 100px; height: 100px;';
+ const wrapper = document.createElement('div');
+ wrapper.append(...Array.from({ length: 1000 }, () => document.createElement('div')));
+ scrollBox.append(wrapper);
+ fixture.append(scrollBox);
+ await elementIsStable(scrollBox);
+ expect(observe).toHaveBeenCalledTimes(2);
+ expect(observe).toHaveBeenCalledWith(scrollBox);
+ expect(observe).toHaveBeenCalledWith(scrollBox.shadowRoot.querySelector('[internal-host]'));
+
+ wrapper.append(document.createElement('div'), document.createTextNode('Message'));
+ await measureContent(scrollBox);
+ wrapper.replaceChildren();
+ await measureContent(scrollBox);
+ expect(observe).toHaveBeenCalledTimes(2);
+ expect(unobserve).not.toHaveBeenCalled();
+ expect(disconnect).not.toHaveBeenCalled();
+ });
+
+ it('should observe text changes', async () => {
+ const observe = vi.spyOn(ResizeObserver.prototype, 'observe');
+ const unobserve = vi.spyOn(ResizeObserver.prototype, 'unobserve');
+ content.style.cssText = 'width: 100px; white-space: nowrap;';
+ content.textContent = 'Short';
+ await vi.waitFor(() => expect(element.hasAttribute('tabindex')).toBe(false));
+ content.firstChild.textContent = 'Long content '.repeat(50);
+ await vi.waitFor(() => expect(element.tabIndex).toBe(0));
+ expect(observe).not.toHaveBeenCalled();
+ expect(unobserve).not.toHaveBeenCalled();
+ });
+
+ it('should stop observers while disconnected and restore them on reconnection', async () => {
+ const resizeDisconnect = vi.spyOn(ResizeObserver.prototype, 'disconnect');
+ const mutationDisconnect = vi.spyOn(MutationObserver.prototype, 'disconnect');
+ element.remove();
+ expect(resizeDisconnect).toHaveBeenCalled();
+ expect(mutationDisconnect).toHaveBeenCalled();
+ content.style.cssText = 'width: 50px; height: 50px;';
+ fixture.append(element);
+ await vi.waitFor(() => expect(element.hasAttribute('tabindex')).toBe(false));
+ content.style.height = '500px';
+ await vi.waitFor(() => expect(element.tabIndex).toBe(0));
+ });
+
+ it('should keep nested scroll events on their native target', async () => {
+ const nested = document.createElement('nve-scroll-box');
+ nested.style.cssText = 'width: 100px; height: 100px;';
+ const child = document.createElement('div');
+ child.style.height = '500px';
+ nested.append(child);
+ content.append(nested);
+ await elementIsStable(nested);
+ const listener = vi.fn();
+ element.addEventListener('scroll', listener);
+ const scrolled = untilEvent(nested, 'scroll');
+ nested.scrollTop = 50;
+ await scrolled;
+ expect(listener).not.toHaveBeenCalled();
+ expect(element.scrollTop).toBe(0);
+ });
+
+ it('should support native scroll snapping on slotted descendants', async () => {
+ const sections = Array.from({ length: 2 }, () => {
+ const section = document.createElement('div');
+ section.style.cssText = 'height: 200px; scroll-snap-align: start;';
+ return section;
+ });
+ content.replaceChildren(...sections);
+ element.style.scrollSnapType = 'y mandatory';
+ element.scrollTo({ top: 170, behavior: 'instant' });
+ await vi.waitFor(() => expect(element.scrollTop).toBe(200));
+ });
+
+ it('should avoid starting observers if removed before its first update', async () => {
+ const scrollBox = document.createElement('nve-scroll-box');
+ scrollBox.style.cssText = 'width: 50px; height: 50px;';
+ scrollBox.append(content);
+ fixture.append(scrollBox);
+ scrollBox.remove();
+ const observe = vi.spyOn(ResizeObserver.prototype, 'observe');
+ await scrollBox.updateComplete;
+ expect(observe).not.toHaveBeenCalledWith(scrollBox);
+ fixture.append(scrollBox);
+ await vi.waitFor(() => expect(scrollBox.tabIndex).toBe(0));
+ expect(observe).toHaveBeenCalledWith(scrollBox);
+ });
+});
diff --git a/projects/foundations/src/scroll-box/scroll-box.test.visual.ts b/projects/foundations/src/scroll-box/scroll-box.test.visual.ts
new file mode 100644
index 0000000000..2b8e335231
--- /dev/null
+++ b/projects/foundations/src/scroll-box/scroll-box.test.visual.ts
@@ -0,0 +1,85 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { afterEach, describe, expect, test } from 'vitest';
+import type { Page } from 'playwright';
+import { visualRunner } from '@internals/vite';
+
+describe('scroll box visual', () => {
+ let browserPage: Page | undefined;
+ afterEach(async () => {
+ await browserPage?.emulateMedia({ forcedColors: 'none' });
+ });
+
+ test.each(['', 'dark'])('scroll box should match visual baseline for theme %s', async theme => {
+ const report = await visualRunner.render(`scroll-box${theme ? '.dark' : ''}`, template(theme), {
+ waitFor: waitFor => waitFor(() => document.body.dataset.ready === 'true')
+ });
+ expect(report.maxDiffPercentage).toBeLessThan(1);
+ });
+
+ test('scroll box should support native keyboard scrolling and descendant focus', async () => {
+ await visualRunner.inspect('scroll-box.focus', template(), async page => {
+ browserPage = page;
+ await page.waitForFunction(() => document.body.dataset.ready === 'true');
+ const scrollBox = page.locator('#initial');
+ await page.keyboard.press('Tab');
+ await expect.poll(() => scrollBox.evaluate(element => element === document.activeElement)).toBe(true);
+ await page.keyboard.press('ArrowDown');
+ await expect.poll(() => scrollBox.evaluate(element => element.scrollTop)).toBeGreaterThan(0);
+ expect(await scrollBox.evaluate(element => getComputedStyle(element).outlineStyle)).not.toBe('none');
+ await page.keyboard.press('Tab');
+ expect(await scrollBox.locator('button').evaluate(element => element === document.activeElement)).toBe(true);
+ await scrollBox.focus();
+ await page.keyboard.press('End');
+ await expect
+ .poll(() => scrollBox.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop))
+ .toBe(0);
+ });
+ });
+
+ test('scroll box should keep native scrolling in forced colors without decorative shadows', async () => {
+ await visualRunner.inspect('scroll-box.forced-colors', template(), async page => {
+ browserPage = page;
+ await page.emulateMedia({ forcedColors: 'active' });
+ await page.waitForFunction(() => document.body.dataset.ready === 'true');
+ expect(await page.locator('#initial .shadow-layer').count()).toBe(1);
+ expect(await page.locator('#initial .shadow-layer').evaluate(element => getComputedStyle(element).display)).toBe(
+ 'none'
+ );
+ await page.locator('#initial').evaluate(element => {
+ element.scrollTop = 100;
+ });
+ expect(await page.locator('#initial').evaluate(element => element.scrollTop)).toBe(100);
+ });
+ });
+});
+
+function template(theme = '') {
+ return /* html */ `
+
+ ${['initial', 'middle', 'end', 'rtl']
+ .map(
+ id => `
+
+
+ •••
+
+
+ `
+ )
+ .join('')}
+
+
+ `;
+}
diff --git a/projects/foundations/src/scroll-box/scroll-box.ts b/projects/foundations/src/scroll-box/scroll-box.ts
new file mode 100644
index 0000000000..a847cd5a7c
--- /dev/null
+++ b/projects/foundations/src/scroll-box/scroll-box.ts
@@ -0,0 +1,248 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { html, LitElement, nothing, type PropertyValues } from 'lit';
+import { property } from 'lit/decorators/property.js';
+import { useStyles, attachInternals } from '@nvidia-elements/foundations/internal';
+import styles from './scroll-box.css?inline';
+
+/**
+ * @element nve-scroll-box
+ * @description A scrollable container for arbitrary content with optional edge shadows and native scrolling APIs.
+ * @documentation https://nvidia.github.io/elements/docs/foundations/scroll-box/
+ * @since 0.0.0
+ * @entrypoint \@nvidia-elements/foundations/scroll-box
+ * @slot - Content to scroll. Use light DOM elements to arrange the content.
+ * @cssprop --scrollbar-color - Native scrollbar thumb and track colors.
+ * @cssprop --scrollbar-width - Native scrollbar width. Set to none to hide scrollbars.
+ * @cssprop --shadow-color - Optional edge tint. Transparent uses the theme shadow.
+ * @cssprop --shadow-size - Size of the edge shadows.
+ */
+export class ScrollBox extends LitElement {
+ static styles = useStyles([styles]);
+
+ static readonly metadata = {
+ tag: 'nve-scroll-box',
+ version: '0.0.0'
+ };
+
+ static get observedAttributes() {
+ return [...super.observedAttributes, 'tabindex'];
+ }
+
+ /** Determines which axes allow user scrolling. */
+ @property({ type: String, reflect: true }) orientation: 'horizontal' | 'vertical' | 'both' = 'both';
+
+ /** Shows shadows at edges where more content is available. */
+ @property({ type: Boolean, reflect: true, attribute: 'scroll-shadow' }) scrollShadow = false;
+
+ /**
+ * Controls vertical scrolling to the bottom. Follow pauses while scrolled away; always returns on content growth or viewport resizing.
+ * Overflow changes from CSS animations inside a fixed-size child do not trigger measurements.
+ */
+ @property({ type: String, attribute: 'auto-scroll' }) autoScroll: 'off' | 'follow' | 'always' = 'off';
+
+ #resizeObserver?: ResizeObserver;
+ #mutationObserver?: MutationObserver;
+ #frame?: number;
+ #authorTabIndex = false;
+ #settingTabIndex = false;
+ #scrollEnd?: number;
+ #scrollPosition = 0;
+ #following = true;
+ #autoScrolling = false;
+
+ /** @private */
+ declare _internals: ElementInternals;
+
+ render() {
+ return html`
+
+ ${
+ this.scrollShadow
+ ? html`
+
+ `
+ : nothing
+ }
+ `;
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ attachInternals(this);
+ this._internals.role = 'region';
+ if (this.hasUpdated) this.#observe();
+ }
+
+ firstUpdated() {
+ this.#observe();
+ }
+
+ updated(changed: PropertyValues) {
+ if (changed.has('autoScroll')) {
+ if (this.#autoScrolling) this.scrollTo({ top: this.scrollTop, behavior: 'instant' });
+ this.#scrollEnd = undefined;
+ this.#following = true;
+ this.#autoScrolling = false;
+ }
+ this.#syncFollowingState();
+ this.#scheduleMeasure();
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ this.#resizeObserver?.disconnect();
+ this.#mutationObserver?.disconnect();
+ this.removeEventListener('load', this.#scheduleMeasure, true);
+ this.removeEventListener('scroll', this.#onScroll);
+ this.removeEventListener('scrollend', this.#onScrollEnd);
+ this.ownerDocument.fonts.removeEventListener('loadingdone', this.#scheduleMeasure);
+ if (this.#frame !== undefined) cancelAnimationFrame(this.#frame);
+ this.#frame = undefined;
+ }
+
+ attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) {
+ super.attributeChangedCallback(name, oldValue, newValue);
+ if (name === 'tabindex' && !this.#settingTabIndex) {
+ this.#authorTabIndex = newValue !== null;
+ this.#scheduleMeasure();
+ }
+ }
+
+ #observe() {
+ if (!this.isConnected) return;
+ // Detaching resets native scroll coordinates. Restore an active follower.
+ if (this.#following) this.#scrollEnd = undefined;
+ this.#resizeObserver = new ResizeObserver(this.#onResize);
+ this.#resizeObserver.observe(this);
+ const content = this.renderRoot.querySelector('[internal-host]');
+ if (content) this.#resizeObserver.observe(content);
+ this.#mutationObserver = new MutationObserver(this.#scheduleMeasure);
+ this.#mutationObserver.observe(this, {
+ attributes: true,
+ childList: true,
+ characterData: true,
+ subtree: true
+ });
+ this.addEventListener('load', this.#scheduleMeasure, true);
+ this.addEventListener('scroll', this.#onScroll);
+ this.addEventListener('scrollend', this.#onScrollEnd);
+ this.ownerDocument.fonts.addEventListener('loadingdone', this.#scheduleMeasure);
+ this.#scheduleMeasure();
+ }
+
+ #scheduleMeasure = () => {
+ if (!this.isConnected || !this.hasUpdated || this.#frame !== undefined) return;
+ this.#frame = requestAnimationFrame(() => {
+ this.#frame = undefined;
+ this.#measure();
+ });
+ };
+
+ #measure() {
+ const layer = this.renderRoot.querySelector('.shadow-layer');
+ // Exclude the decorative layer so content can shrink after a previous overflow.
+ if (layer) layer.hidden = true;
+ this.#syncTabIndex();
+ this.#syncAutoScroll();
+
+ const shadows = layer?.querySelector('.shadows');
+ if (!layer || !shadows) return;
+ this.#sizeShadows(layer, shadows);
+ }
+
+ #onScroll = () => {
+ if (this.clientHeight === 0) return;
+ // Upward movement interrupts our own smooth scrolling too.
+ if (this.scrollTop < this.#scrollPosition - 1) this.#autoScrolling = false;
+ if (!this.#autoScrolling) {
+ const end = Math.min(this.#scrollEnd ?? 0, this.scrollHeight - this.clientHeight);
+ this.#following = this.scrollTop >= end - 1;
+ }
+ this.#scrollPosition = this.scrollTop;
+ this.#syncFollowingState();
+ };
+
+ #onScrollEnd = () => {
+ this.#autoScrolling = false;
+ this.#onScroll();
+ };
+
+ #onResize = () => {
+ // Always also applies when resizing leaves the bottom coordinate unchanged.
+ if (this.autoScroll === 'always') this.#scrollEnd = undefined;
+ this.#scheduleMeasure();
+ };
+
+ #syncFollowingState() {
+ const following =
+ this.#following &&
+ this.orientation !== 'horizontal' &&
+ (this.autoScroll === 'follow' || this.autoScroll === 'always');
+ if (following) this._internals.states.add('following');
+ else this._internals.states.delete('following');
+ }
+
+ #syncAutoScroll() {
+ if (!['follow', 'always'].includes(this.autoScroll)) return;
+ if (this.orientation === 'horizontal' || this.clientHeight === 0 || this.clientWidth === 0) return;
+
+ const end = Math.max(0, this.scrollHeight - this.clientHeight);
+ // A scroll can precede its event. Compare against the previous layout so
+ // content growth does not look like the user scrolling away from the bottom.
+ this.#onScroll();
+ if (this.#scrollEnd === end) return;
+ const scroll = this.#scrollEnd === undefined || this.autoScroll === 'always' || this.#following;
+ this.#scrollEnd = end;
+
+ if (scroll && Math.abs(end - this.scrollTop) > 1) {
+ this.#autoScrolling = true;
+ this.#following = true;
+ this.scrollTo({ top: end, behavior: 'auto' });
+ }
+ this.#onScroll();
+ }
+
+ #sizeShadows(layer: HTMLElement, shadows: HTMLElement) {
+ const { clientWidth, clientHeight, scrollWidth, scrollHeight } = this;
+ const style = getComputedStyle(this);
+ layer.style.width = `${scrollWidth}px`;
+ layer.style.height = `${scrollHeight}px`;
+ shadows.style.width = `${clientWidth}px`;
+ shadows.style.height = `${clientHeight}px`;
+ // Guard disabled axes because scroll-state queries can include hidden overflow.
+ shadows.style.setProperty('--_shadow-x', ['auto', 'scroll'].includes(style.overflowX) ? '1' : '0');
+ shadows.style.setProperty('--_shadow-y', ['auto', 'scroll'].includes(style.overflowY) ? '1' : '0');
+ // Sticky offsets start inside the scrollport's padding.
+ shadows.style.inset = [style.paddingTop, style.paddingRight, style.paddingBottom, style.paddingLeft]
+ .map(value => `${-parseFloat(value)}px`)
+ .join(' ');
+ layer.hidden = clientWidth === 0 || clientHeight === 0;
+ }
+
+ #syncTabIndex() {
+ if (this.#authorTabIndex) return;
+ const { clientWidth, clientHeight, scrollWidth, scrollHeight } = this;
+ const style = getComputedStyle(this);
+ const horizontal = ['auto', 'scroll'].includes(style.overflowX) && scrollWidth > clientWidth;
+ const vertical = ['auto', 'scroll'].includes(style.overflowY) && scrollHeight > clientHeight;
+
+ const value = clientWidth > 0 && clientHeight > 0 && (horizontal || vertical) ? '0' : null;
+ if (this.getAttribute('tabindex') === value) return;
+ this.#settingTabIndex = true;
+ try {
+ if (value === null) this.removeAttribute('tabindex');
+ else this.setAttribute('tabindex', value);
+ } finally {
+ this.#settingTabIndex = false;
+ }
+ }
+}
diff --git a/projects/foundations/vite.config.ts b/projects/foundations/vite.config.ts
index 4987e95eaa..1e3b8de2b3 100644
--- a/projects/foundations/vite.config.ts
+++ b/projects/foundations/vite.config.ts
@@ -6,6 +6,12 @@ export default defineConfig(() => {
const config: UserConfig = {
resolve: {
alias: { '@nvidia-elements/foundations': resolve(import.meta.dirname, './src') }
+ },
+ build: {
+ rolldownOptions: {
+ // Examples use core components supplied by the documentation site.
+ external: [/^@nvidia-elements\/core(?:\/|$)/]
+ }
}
};
diff --git a/projects/foundations/vitest.config.ts b/projects/foundations/vitest.config.ts
index 2ed3b888d7..5ccb181afd 100644
--- a/projects/foundations/vitest.config.ts
+++ b/projects/foundations/vitest.config.ts
@@ -2,14 +2,13 @@ import { resolve } from 'path';
import { mergeConfig } from 'vitest/config';
import { libraryTestConfig } from '@internals/vite/configs/test.js';
-const config = mergeConfig(libraryTestConfig, {
+export default mergeConfig(libraryTestConfig, {
root: import.meta.dirname,
resolve: {
alias: { '@nvidia-elements/foundations': resolve(import.meta.dirname, './src') }
},
test: {
include: ['./src/**/*.test.ts'],
- setupFiles: [],
coverage: {
thresholds: {
lines: 90,
@@ -20,7 +19,3 @@ const config = mergeConfig(libraryTestConfig, {
}
}
});
-
-config.test.setupFiles = [];
-
-export default config;
diff --git a/projects/foundations/vitest.visual.html b/projects/foundations/vitest.visual.html
index 9f772650a6..48f22f0817 100644
--- a/projects/foundations/vitest.visual.html
+++ b/projects/foundations/vitest.visual.html
@@ -25,8 +25,8 @@
body {
display: flex;
flex-direction: column;
- gap: var(--nve-ref-space-md);
- padding: var(--nve-ref-space-md);
+ gap: 8px;
+ padding: 8px;
min-width: 480px;
}
diff --git a/projects/internals/metadata/package.json b/projects/internals/metadata/package.json
index 920ab5a66d..17bc091fa7 100644
--- a/projects/internals/metadata/package.json
+++ b/projects/internals/metadata/package.json
@@ -374,8 +374,10 @@
"../../monaco/dist/**/*.examples.json",
"../../code/dist/**/*.examples.json",
"../../forms/dist/**/*.examples.json",
+ "../../foundations/dist/**/*.examples.json",
"../../markdown/dist/**/*.examples.json",
"../../media/dist/**/*.examples.json",
+ "../../plot/dist/**/*.examples.json",
"../../scene/dist/**/*.examples.json",
"../../viewport/dist/**/*.examples.json"
],
@@ -423,6 +425,14 @@
"script": "../../viewport:build",
"cascade": false
},
+ {
+ "script": "../../foundations:build",
+ "cascade": false
+ },
+ {
+ "script": "../../plot:build",
+ "cascade": false
+ },
{
"script": "../patterns:build",
"cascade": false
diff --git a/projects/markdown/src/markdown/markdown.examples.ts b/projects/markdown/src/markdown/markdown.examples.ts
index c10be762fa..09efd746e4 100644
--- a/projects/markdown/src/markdown/markdown.examples.ts
+++ b/projects/markdown/src/markdown/markdown.examples.ts
@@ -259,9 +259,15 @@ export const StreamingContent = {
Restart stream
-
+
+
+