diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 68127ace10..9039269ee2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1592,6 +1592,9 @@ importers:
'@nvidia-elements/themes':
specifier: workspace:*
version: link:../themes
+ '@vitest/browser':
+ specifier: 'catalog:'
+ version: 4.1.10(vite@8.1.4(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(sugarss@5.0.1(postcss@8.5.16))(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.10)
'@vitest/coverage-istanbul':
specifier: 'catalog:'
version: 5.0.0(vitest@5.0.0)
@@ -13093,8 +13096,8 @@ packages:
third-party-web@0.27.0:
resolution: {integrity: sha512-h0JYX+dO2Zr3abCQpS6/uFjujaOjA1DyDzGQ41+oFn9VW/ARiq9g5ln7qEP9+BTzDpOMyIfsfj4OvfgXAsMUSA==}
- third-party-web@0.29.2:
- resolution: {integrity: sha512-fegtha91tq2DHphyoiBXVHjVi2YG9zFaRnboT9C28tO1en9Y3wJsfspuy40F+u5wl3hHVbw7cnd1b67kEGHb8g==}
+ third-party-web@0.30.0:
+ resolution: {integrity: sha512-p+PfyL5U0ediGzvwPzMSdD9MnC8rHW6nWJJVLIQvqN7RsSqfaZ+7n4c9GILqH36EYR0eXB9CzAeCRnKFr/9uJg==}
three-forcegraph@1.43.4:
resolution: {integrity: sha512-FtmiZP/T16ZQaHza3JDaDn0YTXFtg9e7pGnTeU8nzu0NNkx7MpWbF/GvmpbQsWHx3rukHtkRv1fTorLPB3FDEA==}
@@ -17433,7 +17436,7 @@ snapshots:
'@paulirish/trace_engine@0.0.61':
dependencies:
legacy-javascript: 0.0.1
- third-party-web: 0.29.2
+ third-party-web: 0.30.0
'@pkgjs/parseargs@0.11.0':
optional: true
@@ -26100,7 +26103,7 @@ snapshots:
third-party-web@0.27.0: {}
- third-party-web@0.29.2: {}
+ third-party-web@0.30.0: {}
three-forcegraph@1.43.4(three@0.184.0):
dependencies:
diff --git a/projects/core/.visual/tag.dark.png b/projects/core/.visual/tag.dark.png
index 63bde8a7ab..ca04a8fb60 100644
--- a/projects/core/.visual/tag.dark.png
+++ b/projects/core/.visual/tag.dark.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:abcdc19adee0c5e79392e5bdae56f652d91ee587e6828bfa0392db3ee24ebe6b
-size 51688
+oid sha256:c0ec8900ed93b7f052a157dab2102c6453c4d18eb2faacfbb388246920514784
+size 74195
diff --git a/projects/core/.visual/tag.png b/projects/core/.visual/tag.png
index 97fe2a2000..c421888eb3 100644
--- a/projects/core/.visual/tag.png
+++ b/projects/core/.visual/tag.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:40a3b9dda2e49d23a44174b07d68d4cbd8feae7cbf32a085c9ee83e7a4bbf2fb
-size 48673
+oid sha256:058613a002a82debb61b64c59ee30809a11771d46cb6d5a65d41cd6bf4e5eb52
+size 71059
diff --git a/projects/core/src/pagination/pagination.test.lighthouse.ts b/projects/core/src/pagination/pagination.test.lighthouse.ts
index 609eb990ad..0478a2a532 100644
--- a/projects/core/src/pagination/pagination.test.lighthouse.ts
+++ b/projects/core/src/pagination/pagination.test.lighthouse.ts
@@ -19,6 +19,6 @@ describe('pagination lighthouse report', () => {
expect(report.scores.performance).toBe(100);
expect(report.scores.accessibility).toBe(100);
expect(report.scores.bestPractices).toBe(100);
- expect(report.payload.javascript.kb).toBeLessThan(38.96);
+ expect(report.payload.javascript.kb).toBeLessThan(39.5);
});
});
diff --git a/projects/core/src/tag/tag.css b/projects/core/src/tag/tag.css
index 25d026c110..bf5d170a1b 100644
--- a/projects/core/src/tag/tag.css
+++ b/projects/core/src/tag/tag.css
@@ -21,6 +21,7 @@
}
[internal-host] {
+ --_color: var(--color);
font-weight: var(--font-weight);
font-size: var(--font-size);
gap: var(--gap);
@@ -40,7 +41,7 @@
white-space: nowrap;
}
-slot {
+slot:not([name]) {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
@@ -51,6 +52,15 @@ slot {
color: inherit !important;
}
+::slotted(nve-icon) {
+ --color: var(--_color);
+}
+
+::slotted(nve-dot) {
+ --color: var(--state-color-background);
+ --status-color: var(--_color);
+}
+
nve-icon {
--color: inherit;
cursor: var(--cursor);
diff --git a/projects/core/src/tag/tag.examples.ts b/projects/core/src/tag/tag.examples.ts
index 12558b4ede..e51e27856b 100644
--- a/projects/core/src/tag/tag.examples.ts
+++ b/projects/core/src/tag/tag.examples.ts
@@ -53,6 +53,7 @@ export const Closable = {
/**
* @summary Read-only tags for displaying static labels and metadata, providing visual context without user interaction capabilities.
+ * @tags test-case
*/
export const Readonly = {
render: () => html`
@@ -75,6 +76,7 @@ export const Group = {
/**
* @summary Comprehensive color palette for tag backgrounds, enabling visual categorization and brand consistency across different content types.
+ * @tags test-case
*/
export const Color = {
render: () => html`
@@ -108,6 +110,7 @@ export const Color = {
/**
* @summary High prominence tags with emphasis styling for important categories and priority labels that require visual attention.
+ * @tags test-case
*/
export const Prominence = {
render: () => html`
@@ -137,6 +140,40 @@ export const Prominence = {
`
};
+/**
+ * @summary Tag with icon, showing how the icon/dot color inherits the tag color.
+ * @tags test-case
+ */
+export const SlottedColor = {
+ render: () => html`
+
+ very words long here 10
+ default 10
+ red-cardinal 10
+ gray-slate 10
+ gray-denim 10
+ blue-indigo 10
+ blue-cobalt 10
+ blue-sky 10
+ teal-cyan 10
+ green-mint 10
+ teal-seafoam 10
+ green-grass 10
+ yellow-amber 10
+ orange-pumpkin 10
+ red-tomato 10
+ pink-magenta 10
+ purple-plum 10
+ purple-violet 10
+ purple-lavender 10
+ pink-rose 10
+ green-jade 10
+ lime-pear 10
+ yellow-nova 10
+ brand-green 10
+
+ `
+};
/**
* @summary Single tag with constrained width, with text overflow behavior and content truncation in limited space.
diff --git a/projects/core/src/tag/tag.test.ts b/projects/core/src/tag/tag.test.ts
index e54bb761f3..41c6fca1b2 100644
--- a/projects/core/src/tag/tag.test.ts
+++ b/projects/core/src/tag/tag.test.ts
@@ -82,4 +82,19 @@ describe(Tag.metadata.tag, () => {
await elementIsStable(element);
expect(element.getAttribute('prominence')).toBe('emphasis');
});
+
+ it('should provide a prefix slot', async () => {
+ await elementIsStable(element);
+ expect(element.shadowRoot.querySelector('slot[name="prefix"]')).toBeTruthy();
+ });
+
+ it('should provide a suffix slot', async () => {
+ await elementIsStable(element);
+ expect(element.shadowRoot.querySelector('slot[name="suffix"]')).toBeTruthy();
+ });
+
+ it('should provide a default slot', async () => {
+ await elementIsStable(element);
+ expect(element.shadowRoot.querySelector('slot:not([name])')).toBeTruthy();
+ });
});
diff --git a/projects/core/src/tag/tag.test.visual.ts b/projects/core/src/tag/tag.test.visual.ts
index cdd760988e..9076cc633e 100644
--- a/projects/core/src/tag/tag.test.visual.ts
+++ b/projects/core/src/tag/tag.test.visual.ts
@@ -20,6 +20,8 @@ function template(theme: '' | 'dark' = '') {
return /* html */ `
@@ -99,5 +101,32 @@ function template(theme: '' | 'dark' = '') {
•︎•︎•︎•︎•︎•︎
•︎•︎•︎•︎•︎•︎
+
+
+ •︎•︎•︎•︎•︎•︎ •︎•︎•︎•︎•︎•︎ •︎•︎•︎•︎•︎•︎ 10
+ •︎•︎•︎•︎•︎•︎ 10
+ •︎•︎•︎•︎•︎•︎ 10
+ •︎•︎•︎•︎•︎•︎ 10
+ •︎•︎•︎•︎•︎•︎ 10
+ •︎•︎•︎•︎•︎•︎ 10
+ •︎•︎•︎•︎•︎•︎ 10
+ •︎•︎•︎•︎•︎•︎ 10
+ •︎•︎•︎•︎•︎•︎ 10
+ •︎•︎•︎•︎•︎•︎ 10
+ •︎•︎•︎•︎•︎•︎ 10
+ •︎•︎•︎•︎•︎•︎ 10
+ •︎•︎•︎•︎•︎•︎ 10
+ •︎•︎•︎•︎•︎•︎ 10
+ •︎•︎•︎•︎•︎•︎ 10
+ •︎•︎•︎•︎•︎•︎ 10
+ •︎•︎•︎•︎•︎•︎ 10
+ •︎•︎•︎•︎•︎•︎ 10
+ •︎•︎•︎•︎•︎•︎ 10
+ •︎•︎•︎•︎•︎•︎ 10
+ •︎•︎•︎•︎•︎•︎ 10
+ •︎•︎•︎•︎•︎•︎ 10
+ •︎•︎•︎•︎•︎•︎ 10
+ •︎•︎•︎•︎•︎•︎ 10
+
`;
}
diff --git a/projects/core/src/tag/tag.ts b/projects/core/src/tag/tag.ts
index 5c2ecbee91..d35a1cfd47 100644
--- a/projects/core/src/tag/tag.ts
+++ b/projects/core/src/tag/tag.ts
@@ -23,6 +23,8 @@ import styles from './tag.css?inline';
* @since 0.10.0
* @entrypoint \@nvidia-elements/core/tag
* @slot - Text or other content that identifies the category or group.
+ * @slot prefix - Optional prefix content, such as a status nve-icon.
+ * @slot suffix - Optional suffix content, such as a nve-dot counter.
* @cssprop --background
* @cssprop --color
* @cssprop --gap
@@ -71,7 +73,9 @@ export class Tag extends ButtonFormControlMixin(LitElement) {
render() {
return html`
+
+
${this.closable ? html` this.#typeClosableController.close()} container="flat" name="cancel" size="sm" role="img" aria-label=${ifDefined(this.i18n.close)}> ` : ''}
`;
diff --git a/projects/internals/metadata/src/services/adoption.service.ts b/projects/internals/metadata/src/services/adoption.service.ts
index 5d1f4ca35f..8c17f9da12 100644
--- a/projects/internals/metadata/src/services/adoption.service.ts
+++ b/projects/internals/metadata/src/services/adoption.service.ts
@@ -24,11 +24,11 @@ export class AdoptionService {
packages: [],
github: {
repository: '',
- stars: 0,
- forks: 0,
- subscribers: 0,
- contributors: 0,
- releases: 0,
+ stars: null,
+ forks: null,
+ subscribers: null,
+ contributors: null,
+ releases: null,
stargazers: [],
errors: []
}
diff --git a/projects/internals/metadata/src/tasks/adoption.ts b/projects/internals/metadata/src/tasks/adoption.ts
index 7df1c67567..7c487dcf9f 100644
--- a/projects/internals/metadata/src/tasks/adoption.ts
+++ b/projects/internals/metadata/src/tasks/adoption.ts
@@ -6,7 +6,12 @@ import { resolve } from 'node:path';
import { getAdoptionData } from './adoption.utils.ts';
const adoption = await getAdoptionData();
+const errors = [...adoption.packages.flatMap(packageData => packageData.errors), ...adoption.github.errors];
writeFileSync(resolve(import.meta.dirname, '../../static/adoption.json'), JSON.stringify(adoption, null, 2));
-console.log('✅ Adoption metrics generated successfully.');
+if (errors.length > 0) {
+ console.warn(`⚠️ Adoption metrics generated with ${errors.length} unavailable source response(s).`);
+} else {
+ console.log('✅ Adoption metrics generated successfully.');
+}
diff --git a/projects/internals/metadata/src/tasks/adoption.utils.test.ts b/projects/internals/metadata/src/tasks/adoption.utils.test.ts
index d18e1c0bf8..8b021d40c2 100644
--- a/projects/internals/metadata/src/tasks/adoption.utils.test.ts
+++ b/projects/internals/metadata/src/tasks/adoption.utils.test.ts
@@ -151,6 +151,12 @@ describe('adoption utilities', () => {
forks: 4,
subscribers: 3
});
+ expect(parseGitHubRepository({ stargazers_count: 0, forks_count: 0, subscribers_count: 0 })).toEqual({
+ stars: 0,
+ forks: 0,
+ subscribers: 0
+ });
+ expect(parseGitHubRepository({})).toEqual({ stars: null, forks: null, subscribers: null });
expect(
parseGitHubPaginationTotal('; rel="last"', 1)
).toBe(68);
@@ -317,6 +323,25 @@ describe('adoption utilities', () => {
expect(metrics.stargazers).toHaveLength(2);
});
+ it('should distinguish unavailable GitHub metrics from real zero values', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => new Response('', { status: 503, statusText: 'Service Unavailable' }))
+ );
+
+ const metrics = await getGitHubMetrics();
+
+ expect(metrics).toMatchObject({
+ stars: null,
+ forks: null,
+ subscribers: null,
+ contributors: null,
+ releases: null,
+ stargazers: []
+ });
+ expect(metrics.errors).not.toHaveLength(0);
+ });
+
it('should create adoption totals without counting unavailable packages as published', () => {
const github = {
repository: 'NVIDIA/elements',
diff --git a/projects/internals/metadata/src/tasks/adoption.utils.ts b/projects/internals/metadata/src/tasks/adoption.utils.ts
index c1af25111e..f0f30a950c 100644
--- a/projects/internals/metadata/src/tasks/adoption.utils.ts
+++ b/projects/internals/metadata/src/tasks/adoption.utils.ts
@@ -29,9 +29,9 @@ interface NpmRegistryMetadata {
}
interface GitHubRepositoryMetrics {
- stars: number;
- forks: number;
- subscribers: number;
+ stars: number | null;
+ forks: number | null;
+ subscribers: number | null;
}
interface GitHubStargazersResult {
@@ -301,9 +301,9 @@ export function parseJsDelivrStats(data: unknown, latestVersion: string | null):
export function parseGitHubRepository(data: unknown): GitHubRepositoryMetrics {
return {
- stars: getNumber(data, 'stargazers_count') ?? 0,
- forks: getNumber(data, 'forks_count') ?? 0,
- subscribers: getNumber(data, 'subscribers_count') ?? 0
+ stars: getNumber(data, 'stargazers_count'),
+ forks: getNumber(data, 'forks_count'),
+ subscribers: getNumber(data, 'subscribers_count')
};
}
@@ -432,22 +432,22 @@ export async function getGitHubMetrics(): Promise {
const errors = [repositoryResult, contributorsResult, releasesResult].flatMap(result =>
result.ok ? [] : [result.error]
);
- const repositoryMetrics = repositoryResult.ok
- ? parseGitHubRepository(repositoryResult.data)
- : parseGitHubRepository({});
+ const repositoryMetrics = repositoryResult.ok ? parseGitHubRepository(repositoryResult.data) : null;
const contributorsFallback =
contributorsResult.ok && Array.isArray(contributorsResult.data) ? contributorsResult.data.length : 0;
const releasesFallback = releasesResult.ok && Array.isArray(releasesResult.data) ? releasesResult.data.length : 0;
return {
repository,
- stars: repositoryMetrics.stars,
- forks: repositoryMetrics.forks,
- subscribers: repositoryMetrics.subscribers,
+ stars: repositoryMetrics?.stars ?? null,
+ forks: repositoryMetrics?.forks ?? null,
+ subscribers: repositoryMetrics?.subscribers ?? null,
contributors: contributorsResult.ok
? parseGitHubPaginationTotal(contributorsResult.headers.get('link'), contributorsFallback)
- : 0,
- releases: releasesResult.ok ? parseGitHubPaginationTotal(releasesResult.headers.get('link'), releasesFallback) : 0,
+ : null,
+ releases: releasesResult.ok
+ ? parseGitHubPaginationTotal(releasesResult.headers.get('link'), releasesFallback)
+ : null,
stargazers: parseGitHubStargazers(stargazersResult.data),
errors: [...errors, ...stargazersResult.errors]
};
diff --git a/projects/internals/metadata/src/tasks/lighthouse.utils.ts b/projects/internals/metadata/src/tasks/lighthouse.utils.ts
index 8ddb4c3659..112901e9d6 100644
--- a/projects/internals/metadata/src/tasks/lighthouse.utils.ts
+++ b/projects/internals/metadata/src/tasks/lighthouse.utils.ts
@@ -16,6 +16,7 @@ export interface LighthouseScores {
'@nvidia-elements/code': { [key: string]: LighthouseElementReport };
'@nvidia-elements/forms': { [key: string]: LighthouseElementReport };
'@nvidia-elements/markdown': { [key: string]: LighthouseElementReport };
+ '@nvidia-elements/media': { [key: string]: LighthouseElementReport };
'@nvidia-elements/monaco': { [key: string]: LighthouseElementReport };
}
@@ -57,6 +58,7 @@ export async function generateLighthouseReport(): Promise {
'@nvidia-elements/code': await loadLighthouseReport('../../../../code'),
'@nvidia-elements/forms': await loadLighthouseReport('../../../../forms'),
'@nvidia-elements/markdown': await loadLighthouseReport('../../../../markdown'),
+ '@nvidia-elements/media': await loadLighthouseReport('../../../../media'),
'@nvidia-elements/monaco': await loadLighthouseReport('../../../../monaco')
};
}
diff --git a/projects/internals/metadata/src/types.ts b/projects/internals/metadata/src/types.ts
index 794aa3bdc9..11014dff6a 100644
--- a/projects/internals/metadata/src/types.ts
+++ b/projects/internals/metadata/src/types.ts
@@ -553,11 +553,11 @@ export interface AdoptionGitHubStargazerMonth {
*/
export interface AdoptionGitHubMetrics {
repository: string;
- stars: number;
- forks: number;
- subscribers: number;
- contributors: number;
- releases: number;
+ stars: number | null;
+ forks: number | null;
+ subscribers: number | null;
+ contributors: number | null;
+ releases: number | null;
stargazers: AdoptionGitHubStargazerMonth[];
errors: AdoptionSourceError[];
}
diff --git a/projects/internals/metadata/static/adoption.json b/projects/internals/metadata/static/adoption.json
index acd1ebd22d..ede0c1f81c 100644
--- a/projects/internals/metadata/static/adoption.json
+++ b/projects/internals/metadata/static/adoption.json
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:d0f4650d1aabb3cdf7a7faa13abc1a3b14016d848854d2e388ccf30993d9a376
-size 201159
+oid sha256:630324999d28fb2d962f39ee7867877ca3b17175a5a017b0029ff86dab7e860e
+size 261417
diff --git a/projects/internals/metadata/static/lighthouse.json b/projects/internals/metadata/static/lighthouse.json
index 1e1dc57112..3fc5b80a93 100644
--- a/projects/internals/metadata/static/lighthouse.json
+++ b/projects/internals/metadata/static/lighthouse.json
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:5cd83aaad8ab9c936412b0991c376782d247d059f6cfe6e69abde3aa5af1f423
-size 61721
+oid sha256:626431290e683873d47f224cb668634c11956b60aed788299f40d4a8aaf82869
+size 67679
diff --git a/projects/internals/metadata/static/releases.json b/projects/internals/metadata/static/releases.json
index b6f89fa601..fea0234cc0 100644
--- a/projects/internals/metadata/static/releases.json
+++ b/projects/internals/metadata/static/releases.json
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:9eddbbb37c1aabdfdc469ea30b62d99955afcf23dd08341297b31ad557256ad3
-size 19515
+oid sha256:62136236308eb17156d2ce572a205e19cb510a48e06e4743f287b279de19b020
+size 23960
diff --git a/projects/internals/metadata/static/tests.json b/projects/internals/metadata/static/tests.json
index 089d8ea4e8..7048a73258 100644
--- a/projects/internals/metadata/static/tests.json
+++ b/projects/internals/metadata/static/tests.json
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:36a471595a611afdc932ecfd0b56d4353709f4aa66ba552a5e1ab33302c5d739
-size 2709336
+oid sha256:003bd0e158bc1fe3dce72b7ef9b0fc7da1d2fbc55a26dcf2b3c3732b979b12d9
+size 2962409
diff --git a/projects/internals/vite/src/runners/playwright.js b/projects/internals/vite/src/runners/playwright.js
index 86d7c12d90..fcac3b2e43 100644
--- a/projects/internals/vite/src/runners/playwright.js
+++ b/projects/internals/vite/src/runners/playwright.js
@@ -117,10 +117,7 @@ export class VitePlaywrightRunner {
}
async close() {
- const report = globSync(resolve(`${this.#root}/**/report.json`)).reduce((p, n) => {
- const file = JSON.parse(fs.readFileSync(n));
- return { ...p, [file.name]: file };
- }, {});
+ const report = collectReports(this.#root);
fs.writeFileSync(`${this.#dist}/report.json`, JSON.stringify(report, null, 2));
await this.#page.close();
@@ -129,6 +126,14 @@ export class VitePlaywrightRunner {
}
}
+/** Collects individual runner reports without re-reading the aggregate report. */
+export function collectReports(root) {
+ return globSync(path.resolve(root, 'dist/*/report.json')).reduce((reports, reportPath) => {
+ const report = JSON.parse(fs.readFileSync(reportPath));
+ return { ...reports, [report.name]: report };
+ }, {});
+}
+
/**
* Builds a given test fixture/template for test runner environments.
*
diff --git a/projects/site/package.json b/projects/site/package.json
index eb3de8bc1a..95d2b3d9b5 100644
--- a/projects/site/package.json
+++ b/projects/site/package.json
@@ -11,6 +11,8 @@
"lint": "wireit",
"lint:fix": "wireit",
"test": "wireit",
+ "test:browser": "wireit",
+ "test:node": "wireit",
"test:lighthouse": "wireit",
"preview": "wireit"
},
@@ -227,11 +229,30 @@
]
},
"test": {
- "command": "vitest run",
+ "dependencies": [
+ "test:browser",
+ "test:node"
+ ]
+ },
+ "test:browser": {
+ "command": "vitest run --config=vite.browser.ts",
+ "files": [
+ "src/**/*.ts",
+ "vite.browser.ts"
+ ],
+ "output": [],
+ "dependencies": [
+ "build",
+ "../internals/vite:ci"
+ ]
+ },
+ "test:node": {
+ "command": "vitest run --config=vitest.config.ts",
"files": [
"src/**/*.js",
"src/**/*.md",
"src/**/*.ts",
+ "!src/**/*.browser.test.ts",
"vitest.config.ts"
],
"output": [],
@@ -290,6 +311,7 @@
"@nvidia-elements/scene": "workspace:*",
"@nvidia-elements/styles": "workspace:*",
"@nvidia-elements/themes": "workspace:*",
+ "@vitest/browser": "catalog:",
"@vitest/coverage-istanbul": "catalog:",
"compare-versions": "6.1.1",
"eslint": "catalog:",
diff --git a/projects/site/src/_11ty/layouts/common.js b/projects/site/src/_11ty/layouts/common.js
index a6b4c55c94..2bc360acca 100644
--- a/projects/site/src/_11ty/layouts/common.js
+++ b/projects/site/src/_11ty/layouts/common.js
@@ -462,7 +462,7 @@ export function renderBasePageHeader(data) {
${ELEMENTS_PLAYGROUND_BASE_URL ? /* html */ `` : ''}
-
+
System Themes
diff --git a/projects/site/src/_11ty/layouts/docs.ts b/projects/site/src/_11ty/layouts/docs.ts
index cb70e363ec..beb7de123b 100644
--- a/projects/site/src/_11ty/layouts/docs.ts
+++ b/projects/site/src/_11ty/layouts/docs.ts
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
import type { ResizeHandle } from '@nvidia-elements/core/resize-handle';
+import type { PagePanelContent } from '@nvidia-elements/core/page';
import type { Tree } from '@nvidia-elements/core/tree';
import { FILTER_VALUES, type DocsSearch } from '../../_internal/search/search.js';
import '../../_internal/canvas/canvas.js';
@@ -9,15 +10,11 @@ import '../../_internal/canvas/canvas.js';
void import('../../_internal/search/search.js');
// panel toggles
-let loadedSystemsPanel = false;
const systemOptionsPanel = globalThis.document.querySelector('#system-options-panel')!;
const systemOptionsPanelBtn = globalThis.document.querySelector('#system-options-panel-btn')!;
systemOptionsPanel.addEventListener('close', () => (systemOptionsPanel.hidden = true));
systemOptionsPanelBtn.addEventListener('click', async () => {
- if (!loadedSystemsPanel) {
- await import('../../_internal/system-settings/system-settings.js');
- loadedSystemsPanel = true;
- }
+ await import('../../_internal/system-settings/system-settings.js');
systemOptionsPanel.hidden = !systemOptionsPanel.hidden;
});
@@ -59,11 +56,9 @@ setTimeout(() => {
scrollToHeading(headerId);
}, 500);
-// preserve scroll position between page transitions
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-const content = globalThis.document.querySelector('#sidenav-panel nve-page-panel-content')!;
+const content = globalThis.document.querySelector('#sidenav-panel nve-page-panel-content')!;
globalThis.window.addEventListener('beforeunload', () => {
- sessionStorage.setItem('sidenav-scroll-position', content.scrollTop);
+ sessionStorage.setItem('sidenav-scroll-position', content.scrollTop.toString());
});
const savedPosition = sessionStorage.getItem('sidenav-scroll-position');
@@ -81,11 +76,14 @@ const docsNav = globalThis.document.querySelector('#docs-nav')!;
let isSearching = false;
let currentFilter: string | null = null;
-/**
- * Syncs the current search query and active filter to URL parameters.
- * Updates browser history without triggering navigation or adding new entries.
- */
-function syncSearchStateToUrl(query: string, filter: string | null) {
+// Listen for any search state change (query input, filter selection, or reset)
+// The search component emits a single 'search-change' event for all state transitions
+docsSearch.addEventListener('search-change', ((event: CustomEvent) => {
+ const { query, filter } = event.detail as { query: string; filter: string | null };
+
+ isSearching = query.length > 0;
+ currentFilter = filter;
+
const currentUrl = new URL(globalThis.location.href);
let urlHasChanged = false;
@@ -114,50 +112,15 @@ function syncSearchStateToUrl(query: string, filter: string | null) {
const newUrl = currentUrl.pathname + currentUrl.search + currentUrl.hash;
globalThis.history.replaceState(null, '', newUrl);
}
-}
-
-// Listen for any search state change (query input, filter selection, or reset)
-// The search component emits a single 'search-change' event for all state transitions
-docsSearch.addEventListener('search-change', ((event: CustomEvent) => {
- const { query, filter } = event.detail as { query: string; filter: string | null };
-
- isSearching = query.length > 0;
- currentFilter = filter;
- syncSearchStateToUrl(query, filter);
// Restore navigation when search is fully cleared (no query and no filter)
if (!isSearching && filter === null) {
- toggleSideNav(true);
+ docsNav.hidden = false;
}
}) as EventListener);
-// Listen for search results loaded
-// Hides the navigation tree and expands the side panel to accommodate results
-docsSearch.addEventListener('search-results', () => {
- toggleSideNav(false);
-});
-
-// Listen for no results found
-// Restores the navigation tree since there are no results to display
-docsSearch.addEventListener('search-no-results', () => {
- toggleSideNav(true);
-});
-
-// Listen for search input blur
-// Restores navigation if user is not actively searching
-docsSearch.addEventListener('search-blur', () => !isSearching && toggleSideNav(true));
-
-const defaultWidth = `${handle.getAttribute('value')}px`;
-const toggleSideNav = (state: boolean) => {
- if (state) {
- // Showing nav - shrink panel to default width
- panel.style.width = defaultWidth;
- } else {
- // Hiding nav for search - expand panel for search results
- panel.style.width = `${handle.max}px`;
- }
- docsNav.hidden = !state;
-};
+docsSearch.addEventListener('search-results', () => (docsNav.hidden = true));
+docsSearch.addEventListener('search-no-results', () => (docsNav.hidden = false));
// Initialize search from URL parameters (if present)
// This allows for shareable search links with pre-filled queries and filters
@@ -196,60 +159,41 @@ if (searchQuery) {
searchInputElement.focus();
// Hide navigation and show search results
- toggleSideNav(false);
+ docsNav.hidden = true;
isSearching = true;
void docsSearch.search(searchQuery);
}
} else if (currentFilter) {
- // If there's only a filter (no query), just apply the filter
await customElements.whenDefined('nvd-search');
await docsSearch.updateComplete;
-
docsSearch.filter = currentFilter;
}
-// Add clickable anchor links to headings
function addHeadingAnchors() {
- // Find all h2 and h3 elements with mkd class
const headings = globalThis.document.querySelectorAll('h2[nve-text*="mkd"], h3[nve-text*="mkd"]');
headings.forEach(heading => {
- // Skip if anchor already exists
if (heading.querySelector('.heading-anchor')) return;
-
- // Get or generate ID
const id = heading.id;
-
- // Create anchor element
const anchor = globalThis.document.createElement('a');
anchor.className = 'heading-anchor';
anchor.href = `${globalThis.window.parent.location.pathname}#${id}`;
anchor.setAttribute('aria-label', 'copy permalink');
anchor.innerHTML = ' ';
- // Add click handler
anchor.addEventListener('click', e => {
e.preventDefault();
-
- // Update URL hash without triggering default scroll
globalThis.history.pushState(null, '', `${globalThis.window.location.pathname}#${id}`);
-
- // Scroll to heading using controlled scroll
scrollToHeading(id);
-
- // Copy to clipboard
if (globalThis.navigator.clipboard) {
void globalThis.navigator.clipboard.writeText(
`${globalThis.window.location.origin}${globalThis.window.location.pathname}#${id}`
);
}
});
-
- // Insert anchor as first child of heading
heading.insertBefore(anchor, heading.firstChild);
});
}
-// Run on initial load
addHeadingAnchors();
diff --git a/projects/site/src/_internal/search/search.browser.test.ts b/projects/site/src/_internal/search/search.browser.test.ts
new file mode 100644
index 0000000000..ce0494d41d
--- /dev/null
+++ b/projects/site/src/_internal/search/search.browser.test.ts
@@ -0,0 +1,130 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { DocsSearch, type PagefindSearchFragment } from './search.js';
+
+const results: PagefindSearchFragment[] = [
+ {
+ url: '/elements/button/',
+ raw_url: '/elements/button/',
+ content: '',
+ excerpt: '',
+ filters: {},
+ meta: { section: 'elements', title: 'Button', tag: 'nve-button' },
+ anchors: []
+ },
+ {
+ url: '/patterns/card/',
+ raw_url: '/patterns/card/',
+ content: '',
+ excerpt: '',
+ filters: {},
+ meta: { section: 'patterns', title: 'Card' },
+ anchors: []
+ }
+];
+
+describe('nvd-search', () => {
+ let element: DocsSearch;
+
+ function shadowElement(selector: string): ElementType {
+ const match = element.shadowRoot?.querySelector(selector);
+ if (!match) throw new Error(`Expected search shadow root to contain ${selector}`);
+ return match;
+ }
+
+ beforeEach(async () => {
+ element = globalThis.document.createElement('nvd-search') as DocsSearch;
+ element.baseUrl = '/elements/';
+ globalThis.document.body.append(element);
+ await element.updateComplete;
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ element.remove();
+ });
+
+ it('defines the component and normalizes its base URL', () => {
+ expect(customElements.get('nvd-search')).toBe(DocsSearch);
+ expect(element.baseUrl).toBe('/elements');
+ });
+
+ it('debounces input changes and emits the current search state', async () => {
+ vi.useFakeTimers();
+ const search = vi.spyOn(element, 'search').mockResolvedValue();
+ const searchChanges: CustomEvent[] = [];
+ element.addEventListener('search-change', event => {
+ if (event instanceof CustomEvent) searchChanges.push(event);
+ });
+ const input = shadowElement('#search-input');
+
+ input.value = 'but';
+ input.dispatchEvent(new InputEvent('input', { bubbles: true }));
+ input.value = 'button';
+ input.dispatchEvent(new InputEvent('input', { bubbles: true }));
+ await vi.advanceTimersByTimeAsync(DocsSearch.SEARCH_DEBOUNCE_MS);
+
+ expect(search).toHaveBeenCalledOnce();
+ expect(search).toHaveBeenCalledWith('button');
+ expect(searchChanges).toHaveLength(1);
+ expect(searchChanges[0]?.detail).toEqual({ query: 'button', filter: null });
+ });
+
+ it('filters rendered results by section', async () => {
+ element.results = results;
+ await element.updateComplete;
+ const patternFilter = Array.from(element.shadowRoot?.querySelectorAll('nve-tag') ?? []).find(tag =>
+ tag.textContent?.includes('Patterns')
+ );
+ if (!patternFilter) throw new Error('Expected a Patterns filter');
+
+ patternFilter.click();
+ await element.updateComplete;
+
+ expect(element.filter).toBe('patterns');
+ expect(Array.from(element.shadowRoot?.querySelectorAll('a') ?? []).map(link => link.textContent)).toEqual([
+ expect.stringContaining('Card')
+ ]);
+ });
+
+ it('emits search status events for the filtered results', async () => {
+ element.results = results;
+ await element.updateComplete;
+ const searchResults = vi.fn();
+ const searchNoResults = vi.fn();
+ element.addEventListener('search-results', searchResults);
+ element.addEventListener('search-no-results', searchNoResults);
+ const filters = Array.from(element.shadowRoot?.querySelectorAll('nve-tag') ?? []);
+ const patternFilter = filters.find(tag => tag.textContent?.includes('Patterns'));
+ const foundationFilter = filters.find(tag => tag.textContent?.includes('Foundations'));
+ if (!patternFilter || !foundationFilter) throw new Error('Expected Patterns and Foundations filters');
+
+ patternFilter.click();
+ foundationFilter.click();
+
+ expect(searchResults).toHaveBeenCalledOnce();
+ expect(searchNoResults).toHaveBeenCalledOnce();
+ });
+
+ it('clears results, the query, and the active filter when reset', async () => {
+ element.results = results;
+ await element.updateComplete;
+ const patternFilter = Array.from(element.shadowRoot?.querySelectorAll('nve-tag') ?? []).find(tag =>
+ tag.textContent?.includes('Patterns')
+ );
+ if (!patternFilter) throw new Error('Expected a Patterns filter');
+ patternFilter.click();
+ await element.updateComplete;
+ const input = shadowElement('#search-input');
+ input.value = 'card';
+
+ shadowElement('#search-reset').click();
+ await element.updateComplete;
+
+ expect(input.value).toBe('');
+ expect(element.results).toEqual([]);
+ expect(element.filter).toBeNull();
+ });
+});
diff --git a/projects/site/src/_internal/search/search.ts b/projects/site/src/_internal/search/search.ts
index 4e94e87466..1359fbd1fe 100644
--- a/projects/site/src/_internal/search/search.ts
+++ b/projects/site/src/_internal/search/search.ts
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
-import { css, html, LitElement, unsafeCSS } from 'lit';
+import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
import { customElement } from 'lit/decorators/custom-element.js';
import { property } from 'lit/decorators/property.js';
import { query } from 'lit/decorators/query.js';
@@ -88,15 +88,20 @@ export class DocsSearch extends LitElement {
nve-tag, nve-tag nve-dot {
cursor: pointer;
}
+
+ a {
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
`
];
/** Max number of search results to display */
static readonly MAX_RESULTS = 20;
- /** Max size of the search cache */
- static readonly CACHE_MAX_SIZE = 50;
-
@property({ type: String, attribute: 'base-url' })
baseUrl!: string;
@@ -113,9 +118,6 @@ export class DocsSearch extends LitElement {
@state()
isSearching = false;
- @state()
- noResults = false;
-
@state()
private currentSearchTerm = '';
@@ -126,7 +128,6 @@ export class DocsSearch extends LitElement {
static readonly SEARCH_DEBOUNCE_MS = 400;
#pagefind!: Pagefind;
- #searchCache = new Map();
#searchDebounceTimerId: ReturnType | undefined;
async #handleChange(event: CustomEvent) {
@@ -159,7 +160,6 @@ export class DocsSearch extends LitElement {
this.results = [];
this.searchInput.value = '';
this.isSearching = false;
- this.noResults = false;
this.currentSearchTerm = '';
this.activeFilter = null;
this.filter = null;
@@ -175,19 +175,6 @@ export class DocsSearch extends LitElement {
this.dispatchEvent(new Event('search-focus', { bubbles: true, composed: true }));
}
- #handleBlur(event: Event) {
- event.preventDefault();
- if (this.searchInput.value === '') {
- this.noResults = false;
- }
-
- this.dispatchEvent(new Event('search-blur', { bubbles: true, composed: true }));
- }
-
- /**
- * Lit lifecycle method called after property updates complete.
- * Syncs the 'filter' attribute (from URL/external) to the internal activeFilter state.
- */
override updated(changedProperties: Map) {
super.updated(changedProperties);
@@ -219,16 +206,6 @@ export class DocsSearch extends LitElement {
}
}
- #evictOldestCacheEntryIfFull() {
- if (this.#searchCache.size < DocsSearch.CACHE_MAX_SIZE) {
- return;
- }
- const firstCacheKey = this.#searchCache.keys().next().value;
- if (firstCacheKey) {
- this.#searchCache.delete(firstCacheKey);
- }
- }
-
async #loadPagefind() {
if (!this.#pagefind) {
const url = `${this.baseUrl}/.pagefind/pagefind.js`;
@@ -246,157 +223,90 @@ export class DocsSearch extends LitElement {
this.currentSearchTerm = term;
if (this.#pagefind) {
- // Check cache first for instant results on repeated searches
- const cached = this.#searchCache.get(term);
- if (cached) {
- this.isSearching = false;
- this.results = cached;
- this.noResults = this.results.length === 0;
- this.dispatchEvent(
- new CustomEvent(this.noResults ? 'search-no-results' : 'search-results', {
- detail: this.results,
- bubbles: true,
- composed: true
- })
- );
- return;
- }
-
this.results = [];
- this.noResults = false;
const searchResult = await this.#pagefind.debouncedSearch(term);
if (searchResult !== null) {
- // Limit results to MAX_RESULTS and fetch data in parallel for better performance
const limitedResults = searchResult.results.slice(0, DocsSearch.MAX_RESULTS);
const queryResults = await Promise.all(limitedResults.map(result => result.data()));
-
this.isSearching = false;
-
- // Assign a new array reference to trigger Lit's reactive updates
this.results = [...queryResults];
- // Update cache with FIFO eviction when cache is full
- this.#evictOldestCacheEntryIfFull();
- this.#searchCache.set(term, this.results);
-
- // Notify parent components of search outcome
- if (this.results.length === 0) {
- this.noResults = true;
- this.dispatchEvent(new Event('search-no-results', { bubbles: true, composed: true }));
- } else {
- this.dispatchEvent(
- new CustomEvent('search-results', {
- detail: this.results,
- bubbles: true,
- composed: true
- })
- );
- }
+ this.#dispatchSearchStatus();
}
}
}
- render() {
- const filteredResults = this.#getFilteredResults();
+ #dispatchSearchStatus() {
+ if (this.#filteredResults.length === 0) {
+ this.dispatchEvent(new Event('search-no-results', { bubbles: true, composed: true }));
+ } else {
+ this.dispatchEvent(
+ new CustomEvent('search-results', {
+ detail: this.results,
+ bubbles: true,
+ composed: true
+ })
+ );
+ }
+ }
+
+ get #filteredResults(): SearchResult[] {
+ const searchWords = this.currentSearchTerm
+ .toLowerCase()
+ .split(/\s+/)
+ .filter(w => w.length > 0);
+
+ return this.results
+ .filter(result => (this.activeFilter ? result.meta?.section === this.activeFilter : true))
+ .flatMap(result => [this.#getSearchResult(result), ...this.#getHeadings(result)])
+ .sort((a, b) => {
+ const aAll = searchWords.every(w => a.title.toLowerCase().includes(w));
+ const bAll = searchWords.every(w => b.title.toLowerCase().includes(w));
+ return aAll === bAll ? 0 : aAll ? -1 : 1;
+ });
+ }
+ render() {
return html`
-
-
+
+
+
+ ${
+ this.isSearching
+ ? html`
+
+
+ `
+ : nothing
+ }
+
+
${
- this.isSearching
+ this.results.length > 0
? html`
-
-
-
- `
- : html``
- }
-
-
- ${
- this.results.length > 0
- ? (() => {
- // Get result counts for each filter category to display in chip labels
- const resultCounts = this.#getFilterCounts();
-
- // Helper to determine if a filter is currently active
- const isFilterActive = (filterName: string | null) => this.activeFilter === filterName;
-
- return html`
-
- ${FILTER_OPTIONS.map(({ value, label, countKey }) => {
- const active = isFilterActive(value);
- return html`
- this.#setFilter(value)}
- >
- ${label} ${resultCounts[countKey]}
-
- `;
- })}
-
- `;
- })()
- : ''
- }
- ${(() => {
- const searchWords = this.currentSearchTerm
- .toLowerCase()
- .split(/\s+/)
- .filter(w => w.length > 0);
- const allResults = filteredResults.flatMap(result => [
- this.#getSearchResult(result),
- ...this.#getHeadings(result)
- ]);
-
- // For multi-word searches, promote results whose title contains ALL words
- if (searchWords.length > 1) {
- allResults.sort((a, b) => {
- const aAll = searchWords.every(w => a.title.toLowerCase().includes(w));
- const bAll = searchWords.every(w => b.title.toLowerCase().includes(w));
- return aAll === bAll ? 0 : aAll ? -1 : 1;
- });
+
+ ${FILTER_OPTIONS.map(({ value, label, countKey }) => {
+ const active = value === this.activeFilter;
+ return html`
+ this.#setFilter(value)}>
+ ${label} ${this.#getFilterCounts()[countKey]}
+ `;
+ })}
+
`
+ : nothing
}
-
- return allResults.map(r => this.#renderSearchResult(r));
- })()}
- ${
- this.noResults || (this.results.length > 0 && filteredResults.length === 0)
- ? (() => {
- // Determine if we're showing "no results at all" vs "no results in this filter"
- const hasUnfilteredResults = this.results.length > 0;
- const isFilteredView = this.activeFilter !== null;
-
- // Choose appropriate message
- const alertMessage = isFilteredView ? `No results in "${this.activeFilter}"` : 'No Results Found';
-
- return html`
-
- ${alertMessage}
-
- ${
- // Show "Show all results" button if there are results in other filters
- isFilteredView && hasUnfilteredResults
- ? html`
- this.#setFilter(null)}
- >
- Show all results
-
- `
- : ''
- }
-
- `;
- })()
- : html``
- }
+ ${this.#filteredResults.map(
+ result => html`
+
+
+
${result.title}
+
${result.subtitle}
+
+ `
+ )}
+
`;
}
@@ -408,6 +318,7 @@ export class DocsSearch extends LitElement {
#setFilter(filter: string | null) {
this.activeFilter = filter;
this.filter = filter;
+ this.#dispatchSearchStatus();
// Notify parent components that the search state has changed
this.dispatchEvent(
@@ -419,23 +330,6 @@ export class DocsSearch extends LitElement {
);
}
- /**
- * Filters search results based on the currently active section filter.
- * @returns Filtered array of search results, or all results if no filter is active
- */
- #getFilteredResults(): PagefindSearchFragment[] {
- // Show all results when no filter is active
- if (!this.activeFilter) {
- return this.results;
- }
-
- // Filter results by matching section metadata
- return this.results.filter(result => {
- const resultSection = result.meta?.section;
- return resultSection === this.activeFilter;
- });
- }
-
/**
* Counts the number of visible search results in each section category.
* Includes both page-level results and heading sub-results to match the rendered output.
@@ -469,51 +363,24 @@ export class DocsSearch extends LitElement {
};
}
- #renderSearchResult(result: SearchResult) {
- return html`
-
-
-
-
-
${result.title}
-
${result.subtitle}
-
-
-
-
- `;
- }
-
#getSearchResult(result: PagefindSearchFragment): SearchResult {
- const isNveTag = result.meta?.tag?.includes('nve-');
- const isApiPage = result.meta?.tab === 'api';
-
- // Determine icon based on page type
- let icon: IconName;
- let colorToken: string;
+ let icon: IconName = 'book';
+ let colorToken = 'yellow-amber';
- if (isApiPage && isNveTag) {
- // API documentation pages for components
- icon = 'code';
- colorToken = 'purple-violet';
- } else if (isNveTag) {
- // Component overview pages
+ if (result.meta?.tag?.includes('nve-')) {
icon = 'terminal';
colorToken = 'blue-cobalt';
- } else {
- // General documentation pages
- icon = 'book';
- colorToken = 'yellow-amber';
+ } else if (result.meta?.tab === 'api') {
+ icon = 'code';
+ colorToken = 'purple-violet';
}
- const style = `--color: var(--nve-ref-color-${colorToken}-1000);`;
-
return {
url: `${result.raw_url ?? ''}${this.#getQueryParam()}`,
title: result.meta?.title,
subtitle: result.raw_url || '',
icon: icon,
- style: style,
+ style: `--color: var(--nve-ref-color-${colorToken}-1000);`,
excerpt: result.excerpt
};
}
diff --git a/projects/site/src/docs/elements/tag.md b/projects/site/src/docs/elements/tag.md
index 365efa033a..3eab337b06 100644
--- a/projects/site/src/docs/elements/tag.md
+++ b/projects/site/src/docs/elements/tag.md
@@ -28,6 +28,10 @@
{% example '@nvidia-elements/core/tag/tag.examples.json' 'Closable' %}
+## Slotted Color
+
+{% example '@nvidia-elements/core/tag/tag.examples.json' 'SlottedColor' %}
+
## Readonly
{% api 'nve-tag', 'property', 'readOnly' %}
diff --git a/projects/site/src/docs/metrics/adoption.ts b/projects/site/src/docs/metrics/adoption.ts
index e686dd69df..17f8fe173f 100644
--- a/projects/site/src/docs/metrics/adoption.ts
+++ b/projects/site/src/docs/metrics/adoption.ts
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
-import { Chart } from 'chart.js/auto';
+import { Chart, type ChartConfiguration } from 'chart.js/auto';
import { getThemeTokens } from '@nvidia-elements/core';
import { AdoptionService } from '@internals/metadata';
import {
@@ -384,7 +384,7 @@ new Chart(getCanvas('adoption-release-overlay-chart'), {
}
});
-new Chart(getCanvas('adoption-github-interest-chart'), {
+const githubInterestChartConfiguration: ChartConfiguration<'line'> = {
type: 'line',
data: {
labels: adoption.github.stargazers.map(stargazer => stargazer.month),
@@ -442,4 +442,18 @@ new Chart(getCanvas('adoption-github-interest-chart'), {
}
}
}
-});
+};
+
+const githubInterestCanvas = globalThis.document.getElementById('adoption-github-interest-chart');
+if (githubInterestCanvas instanceof HTMLCanvasElement) {
+ if ('IntersectionObserver' in globalThis) {
+ const observer = new IntersectionObserver(entries => {
+ if (!entries.some(entry => entry.isIntersecting)) return;
+ observer.disconnect();
+ new Chart(githubInterestCanvas, githubInterestChartConfiguration);
+ });
+ observer.observe(githubInterestCanvas);
+ } else {
+ new Chart(githubInterestCanvas, githubInterestChartConfiguration);
+ }
+}
diff --git a/projects/site/src/docs/metrics/index.11ty.js b/projects/site/src/docs/metrics/index.11ty.js
index f8ad815544..d405932726 100644
--- a/projects/site/src/docs/metrics/index.11ty.js
+++ b/projects/site/src/docs/metrics/index.11ty.js
@@ -64,6 +64,11 @@ function getTopCdnVersion(packageData) {
: 'None';
}
+function renderOptionalCount(value, label) {
+ const formattedValue = value === null ? 'unavailable' : value.toLocaleString();
+ return `${formattedValue} ${label} `;
+}
+
export function render() {
return this.renderTemplate(
/* html */ `
@@ -106,8 +111,8 @@ export function render() {
${formatNumber(adoption.totals.npmDownloads)} npm downloads
${formatNumber(adoption.totals.cdnRequests)} CDN requests
- ${formatNumber(adoption.github.stars)} stars
- ${formatNumber(adoption.github.contributors)} contributors
+ ${renderOptionalCount(adoption.github.stars, 'stars')}
+ ${renderOptionalCount(adoption.github.contributors, 'contributors')}
@@ -207,7 +212,11 @@ export function render() {
Public stargazer growth from GitHub API timestamps.
-
+ ${
+ adoption.github.stargazers.length > 0
+ ? ' '
+ : 'GitHub star history is unavailable.
'
+ }
diff --git a/projects/site/vite.browser.ts b/projects/site/vite.browser.ts
new file mode 100644
index 0000000000..b98b292d84
--- /dev/null
+++ b/projects/site/vite.browser.ts
@@ -0,0 +1,16 @@
+import { mergeConfig } from 'vitest/config';
+import { libraryTestConfig } from '@internals/vite/configs/test.js';
+
+export default mergeConfig(libraryTestConfig, {
+ root: import.meta.dirname,
+ test: {
+ include: ['./src/**/*.browser.test.ts'],
+ outputFile: {
+ junit: './coverage/browser/junit.xml',
+ json: './coverage/browser/summary.json'
+ },
+ coverage: {
+ reportsDirectory: './coverage/browser'
+ }
+ }
+});
diff --git a/projects/site/vitest.config.ts b/projects/site/vitest.config.ts
index 48e5532e49..9fdead25dc 100644
--- a/projects/site/vitest.config.ts
+++ b/projects/site/vitest.config.ts
@@ -4,6 +4,7 @@ import { libraryNodeTestConfig } from '@internals/vite/configs/test.node.js';
export default mergeConfig(libraryNodeTestConfig, {
root: import.meta.dirname,
test: {
- include: ['./src/**/*.test.ts']
+ include: ['./src/**/*.test.ts'],
+ exclude: ['./src/**/*.browser.test.ts']
}
});