From b19d1b47d54e152697a3de6129bc8350083d4b77 Mon Sep 17 00:00:00 2001 From: Cory Rylan Date: Wed, 14 Oct 2026 09:29:35 -0400 Subject: [PATCH] feat(foundations): scroll box Signed-off-by: Cory Rylan --- package.json | 16 +- pnpm-lock.yaml | 12 +- .../foundations/.visual/foundations.dark.png | 3 - projects/foundations/.visual/foundations.png | 3 - .../foundations/.visual/scroll-box.dark.png | 3 + .../foundations/.visual/scroll-box.focus.png | 3 + .../.visual/scroll-box.forced-colors.png | 3 + projects/foundations/.visual/scroll-box.png | 3 + projects/foundations/README.md | 49 +- projects/foundations/eslint.config.js | 15 +- projects/foundations/package.json | 42 +- .../src/foundations/foundations.css | 6 - .../src/foundations/foundations.examples.ts | 19 - .../src/foundations/foundations.test.axe.ts | 31 - .../foundations.test.lighthouse.ts | 21 - .../src/foundations/foundations.test.ssr.ts | 20 - .../src/foundations/foundations.test.ts | 44 -- .../foundations/foundations.test.visual.ts | 27 - .../src/foundations/foundations.ts | 31 - .../foundations/src/internal/define.test.ts | 33 + projects/foundations/src/internal/define.ts | 13 + projects/foundations/src/internal/index.ts | 7 + .../foundations/src/internal/styles/base.css | 63 ++ .../foundations/src/internal/styles/index.ts | 10 + .../src/internal/styles/interaction-state.css | 46 ++ .../src/internal/utils/a11y.test.ts | 41 ++ .../foundations/src/internal/utils/a11y.ts | 5 + .../src/{foundations => scroll-box}/define.ts | 8 +- .../src/{foundations => scroll-box}/index.ts | 2 +- .../foundations/src/scroll-box/scroll-box.css | 141 ++++ .../src/scroll-box/scroll-box.examples.ts | 121 ++++ .../src/scroll-box/scroll-box.test.axe.ts | 47 ++ .../scroll-box/scroll-box.test.lighthouse.ts | 22 + .../src/scroll-box/scroll-box.test.ssr.ts | 37 + .../src/scroll-box/scroll-box.test.ts | 647 ++++++++++++++++++ .../src/scroll-box/scroll-box.test.visual.ts | 85 +++ .../foundations/src/scroll-box/scroll-box.ts | 248 +++++++ projects/foundations/vite.config.ts | 6 + projects/foundations/vitest.config.ts | 7 +- projects/foundations/vitest.visual.html | 4 +- projects/internals/metadata/package.json | 10 + .../src/markdown/markdown.examples.ts | 8 +- projects/site/src/_11ty/layouts/common.js | 3 +- projects/site/src/_11ty/layouts/docs.11ty.js | 3 +- projects/site/src/_11ty/shortcodes/index.js | 15 +- .../site/src/docs/foundations/scroll-box.md | 43 ++ .../docs/internal/guidelines/documentation.md | 15 +- projects/site/src/index.test.lighthouse.ts | 12 +- 48 files changed, 1792 insertions(+), 261 deletions(-) delete mode 100644 projects/foundations/.visual/foundations.dark.png delete mode 100644 projects/foundations/.visual/foundations.png create mode 100644 projects/foundations/.visual/scroll-box.dark.png create mode 100644 projects/foundations/.visual/scroll-box.focus.png create mode 100644 projects/foundations/.visual/scroll-box.forced-colors.png create mode 100644 projects/foundations/.visual/scroll-box.png delete mode 100644 projects/foundations/src/foundations/foundations.css delete mode 100644 projects/foundations/src/foundations/foundations.examples.ts delete mode 100644 projects/foundations/src/foundations/foundations.test.axe.ts delete mode 100644 projects/foundations/src/foundations/foundations.test.lighthouse.ts delete mode 100644 projects/foundations/src/foundations/foundations.test.ssr.ts delete mode 100644 projects/foundations/src/foundations/foundations.test.ts delete mode 100644 projects/foundations/src/foundations/foundations.test.visual.ts delete mode 100644 projects/foundations/src/foundations/foundations.ts create mode 100644 projects/foundations/src/internal/define.test.ts create mode 100644 projects/foundations/src/internal/define.ts create mode 100644 projects/foundations/src/internal/index.ts create mode 100644 projects/foundations/src/internal/styles/base.css create mode 100644 projects/foundations/src/internal/styles/index.ts create mode 100644 projects/foundations/src/internal/styles/interaction-state.css create mode 100644 projects/foundations/src/internal/utils/a11y.test.ts create mode 100644 projects/foundations/src/internal/utils/a11y.ts rename projects/foundations/src/{foundations => scroll-box}/define.ts (51%) rename projects/foundations/src/{foundations => scroll-box}/index.ts (80%) create mode 100644 projects/foundations/src/scroll-box/scroll-box.css create mode 100644 projects/foundations/src/scroll-box/scroll-box.examples.ts create mode 100644 projects/foundations/src/scroll-box/scroll-box.test.axe.ts create mode 100644 projects/foundations/src/scroll-box/scroll-box.test.lighthouse.ts create mode 100644 projects/foundations/src/scroll-box/scroll-box.test.ssr.ts create mode 100644 projects/foundations/src/scroll-box/scroll-box.test.ts create mode 100644 projects/foundations/src/scroll-box/scroll-box.test.visual.ts create mode 100644 projects/foundations/src/scroll-box/scroll-box.ts create mode 100644 projects/site/src/docs/foundations/scroll-box.md 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` + +
+
+ `); + 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` + +
+
+ ` + )} + `); + 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
- + + +