Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions projects/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,17 @@ Install to Cursor with the MCP configuration below.
}
```

### Codex

Install to Codex with the MCP configuration below.

```toml
[mcp_servers.elements]
description = "NVIDIA Elements UI Design System (nve-*), custom element schemas, APIs and examples"
command = "nve"
args = ["mcp"]
```

### Prompts

| Prompt | Description | Example Prompt |
Expand Down
4 changes: 2 additions & 2 deletions projects/code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -190,8 +190,8 @@
"lit-html": "catalog:publish"
},
"peerDependencies": {
"@nvidia-elements/core": "workspace:>=0.0.0 <1.0.0",
"@nvidia-elements/themes": "workspace:>=0.0.0 <1.0.0"
"@nvidia-elements/core": "workspace:^",
"@nvidia-elements/themes": "workspace:^"
Comment on lines +193 to +194

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify lint-vs-health contract mismatch for workspace peer specifiers.

sed -n '52,90p' projects/lint/src/eslint/rules/no-unexpected-library-dependencies.ts
sed -n '168,190p' projects/internals/tools/src/project/health.ts
rg -n '"`@nvidia-elements/`(core|themes)"\s*:\s*"workspace:\^"' --iglob 'projects/**/package.json'

Repository: NVIDIA/elements

Length of output: 3237


Update semantic dependency health to accept workspace:/catalog: peer specifiers

projects/internals/tools/src/project/health.ts’s checkSemanticDependencies currently flags any @nvidia-elements/* peerDependency whose version does not start with ^, so workspace:^ in projects/code/package.json lines 193-194 will still be reported as danger (“must contain caret (^) prefix”). Align it with the lint rule by allowing workspace: (and catalog:).

Suggested fix at root cause (`projects/internals/tools/src/project/health.ts`)
 export function checkSemanticDependencies(packageJson: PackageData): ReportCheck {
   const hasPinnedVersion = Object.entries(packageJson.peerDependencies ?? {}).find(
-    ([key, value]) => key.includes('`@nvidia-elements`') && !value.startsWith('^')
+    ([key, value]) =>
+      key.includes('`@nvidia-elements`') &&
+      !value.startsWith('^') &&
+      !value.startsWith('workspace:') &&
+      !value.startsWith('catalog:')
   );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@projects/code/package.json` around lines 193 - 194, The
checkSemanticDependencies function in
projects/internals/tools/src/project/health.ts incorrectly treats peerDependency
versions that start with "workspace:" or "catalog:" as missing the caret; update
the validation logic inside checkSemanticDependencies to accept versions that
start with "^" OR start with "workspace:" OR start with "catalog:" (i.e., allow
these specifiers as valid) so entries like "`@nvidia-elements/core`":
"workspace:^" and "`@nvidia-elements/themes`": "workspace:^" are not flagged as
danger; adjust any error message or condition that currently enforces "must
contain caret (^) prefix" to reflect the expanded allowed prefixes.

},
"devDependencies": {
"@eslint/js": "catalog:",
Expand Down
2 changes: 1 addition & 1 deletion projects/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -939,7 +939,7 @@
"lit-html": "catalog:publish"
},
"peerDependencies": {
"@nvidia-elements/themes": "workspace:>=0.0.0 <1.0.0"
"@nvidia-elements/themes": "workspace:^"
},
"optionalDependencies": {
"@lit-labs/scoped-registry-mixin": "catalog:publish",
Expand Down
3 changes: 1 addition & 2 deletions projects/core/src/combobox/combobox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,10 @@ describe(Combobox.metadata.tag, () => {
expect(dropdown.matches(':popover-open')).toBe(true);
});

it('should assign trigger and anchor to inner input container', async () => {
it('should assign anchor to inner input container', async () => {
const dropdown = element.shadowRoot.querySelector<Dropdown>(Dropdown.metadata.tag);
const inputContainer = element.shadowRoot.querySelector<HTMLDivElement>('[input]');
expect(dropdown.anchor).toBe(inputContainer);
expect(dropdown.trigger).toBe(inputContainer);
});

it('should hide options on escape keypress', async () => {
Expand Down
18 changes: 9 additions & 9 deletions projects/core/src/combobox/combobox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ export class Combobox extends Control implements ContainerElement {
const hasNoResults = visibleOptions.filter(o => !o.disabled).length === 0;
const showCreateItem = this.#showCreateItem;
return html`
<nve-dropdown part="dropdown" .popoverType=${'manual'} .modal=${false} @open=${this.#onDropdownOpen} @close=${this.#closeListBox} hidden .anchor=${this.#input as HTMLElement} .trigger=${this.#input as HTMLElement} position="bottom">
<nve-dropdown part="dropdown" .popoverType=${'manual'} .modal=${false} @open=${this.#openDropdown} @close=${this.#closeDropdown} hidden .anchor=${this.#input as HTMLElement} position="bottom">
<nve-menu part="menu" role="listbox" style="--width: 100%; --min-width: fit-content" aria-label=${ifDefined(this.i18n.select)}>
${visibleOptions.map(
o => html`
Expand Down Expand Up @@ -371,8 +371,14 @@ export class Combobox extends Control implements ContainerElement {
}
}

#onDropdownOpen(e: Event) {
(e.target as HTMLElement).hidden = false;
#openDropdown() {
this.#dropdown!.hidden = false;
}

#closeDropdown() {
this.#dropdown!.hidden = true;
this._internals.states.delete('dirty');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I get the sense there are two competing meanings of "dirty" at play here.

  1. the traditional "this field was changed vs the initial state" per Control where any input event sets this state (and form reset clears it).
  2. a transient (while open) state unique to the combobox around the search and highlighting

Maybe this needs a distinct state? Otherwise, unconditionally clearing on close makes this behave differently than other controls.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that makes sense, I was thinking in terms of "active" it shouldn't be treated as "dirty". I'll fix that

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, going to likely follow up on that change since dirty was the prior behavior. It looks like thats used to determine the initial filtering state on open. I need to figure out the exact logic that the base form control has that sets dirty and emulate that here to ensure it doesnt break any prior behavior

this.#validateSingleSelectValue();
}

#setupAutoCompleteKeyEvents() {
Expand Down Expand Up @@ -515,12 +521,6 @@ export class Combobox extends Control implements ContainerElement {
}
}

#closeListBox() {
this.#dropdown!.hidePopover();
this._internals.states.delete('dirty');
this.#validateSingleSelectValue();
}

#validateSingleSelectValue() {
const invalidInputValue =
this.#select &&
Expand Down
6 changes: 6 additions & 0 deletions projects/core/src/select/select.global.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */
/* SPDX-License-Identifier: Apache-2.0 */

nve-select select[multiple] option {
pointer-events: none !important;
}
11 changes: 9 additions & 2 deletions projects/core/src/select/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@ import {
I18nController,
onChildListMutation,
getElementUpdate,
scopedRegistry
scopedRegistry,
appendRootNodeStyle
} from '@nvidia-elements/core/internal';
import { Control } from '@nvidia-elements/core/forms';
import { Icon } from '@nvidia-elements/core/icon';
import { Menu, MenuItem } from '@nvidia-elements/core/menu';
import { Dropdown } from '@nvidia-elements/core/dropdown';
import { Tag } from '@nvidia-elements/core/tag';
import styles from './select.css?inline';
import globalStyles from './select.global.css?inline';

/**
* @element nve-select
Expand Down Expand Up @@ -156,7 +158,7 @@ export class Select extends Control {
return this.#select?.size === 0
? html`
<nve-icon name="caret" part="caret" direction="down" size="sm" aria-hidden="true"></nve-icon>
<nve-dropdown part="dropdown" @close=${this.#closeDropdown} @open=${this.#openDropdown} hidden .anchor=${this.#input as HTMLElement} .trigger=${this.#input as HTMLElement} position="bottom">
<nve-dropdown part="dropdown" @close=${this.#closeDropdown} @open=${this.#openDropdown} hidden .anchor=${this.#input as HTMLElement} position="bottom">
${this.#menu}
</nve-dropdown>`
: this.#menu;
Expand Down Expand Up @@ -189,6 +191,11 @@ export class Select extends Control {
});
}

connectedCallback() {
super.connectedCallback();
appendRootNodeStyle(this, globalStyles);
}

disconnectedCallback() {
super.disconnectedCallback();
this.#observers.forEach(observer => observer.disconnect());
Expand Down
89 changes: 89 additions & 0 deletions projects/internals/tools/src/api/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,14 @@ import {
getContextAPIs,
getContextTokens,
getPublishedPackageNames,
searchContextAPIs,
type PartialAPIResult
} from './utils.js';

vi.mock('@internals/metadata', () => ({
ApiService: { search: vi.fn() }
}));

describe('getPublishedPackageNames', () => {
const projects = [
{
Expand Down Expand Up @@ -613,4 +618,88 @@ describe('attributeMetadataToMarkdown', () => {

expect(markdown.includes('| `disabled` | `string` |`true` |')).toBe(true);
});

it('should use the built-in example for nve-layout', () => {
const attribute: Attribute = {
name: 'nve-layout',
description: 'Layout utility attribute',
example: '',
markdown: '',
values: [{ name: 'row' }, { name: 'column' }]
};

const markdown = attributeMetadataToMarkdown(attribute);

expect(markdown).toContain('## nve-layout');
expect(markdown).toContain('nve-layout="row gap:sm"');
expect(markdown).toContain('nve-layout="grid gap:sm span-items:6"');
});

it('should use the built-in example for nve-text', () => {
const attribute: Attribute = {
name: 'nve-text',
description: 'Typography utility attribute',
example: '',
markdown: '',
values: [{ name: 'heading' }, { name: 'body' }]
};

const markdown = attributeMetadataToMarkdown(attribute);

expect(markdown).toContain('## nve-text');
expect(markdown).toContain('nve-text="heading"');
expect(markdown).toContain('nve-text="monospace"');
});
});

describe('searchContextAPIs', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('should attach markdown to attribute results that have values', async () => {
const { ApiService } = await import('@internals/metadata');
vi.mocked(ApiService.search).mockResolvedValue([
{ name: 'nve-layout', description: 'Layout utility', values: [{ name: 'row' }], markdown: '' }
] as never);

const results = (await searchContextAPIs('layout')) as Attribute[];

expect(results).toHaveLength(1);
expect(results[0].markdown).toContain('## nve-layout');
});

it('should leave element results without a markdown field untouched', async () => {
const { ApiService } = await import('@internals/metadata');
vi.mocked(ApiService.search).mockResolvedValue([
{ name: 'nve-button', manifest: { metadata: { markdown: 'x' } } }
] as never);

const results = (await searchContextAPIs('button')) as Element[];

expect(results).toHaveLength(1);
expect((results[0] as Attribute).markdown).toBeUndefined();
});

it('should limit results to the configured limit', async () => {
const { ApiService } = await import('@internals/metadata');
vi.mocked(ApiService.search).mockResolvedValue(
Array.from({ length: 5 }, (_, index) => ({ name: `nve-item-${index}` })) as never
);

const results = await searchContextAPIs('item', { limit: 2 });

expect(results).toHaveLength(2);
});

it('should return every result when no limit is provided', async () => {
const { ApiService } = await import('@internals/metadata');
vi.mocked(ApiService.search).mockResolvedValue(
Array.from({ length: 5 }, (_, index) => ({ name: `nve-item-${index}` })) as never
);

const results = await searchContextAPIs('item', {});

expect(results).toHaveLength(5);
});
});
22 changes: 22 additions & 0 deletions projects/internals/tools/src/distill/examples.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ describe('isContextExample', () => {
})
).toBe(false);
});

it('should treat an example with no id, tags, or element as a default', () => {
expect(isContextExample({})).toBe(true);
});
});

describe('rankExample', () => {
Expand All @@ -147,6 +151,10 @@ describe('rankExample', () => {
expect(rankExample({ id: 'button-default' })).toBe(3);
});

it('should default to the lowest rank when the id is missing', () => {
expect(rankExample({})).toBe(3);
});

it('should strip elements- prefix before ranking', () => {
expect(rankExample({ id: 'elements-template-foo' })).toBe(0);
expect(rankExample({ id: 'elements-pattern-form' })).toBe(1);
Expand Down Expand Up @@ -267,4 +275,18 @@ describe('distillExamples', () => {
expect(result).toHaveLength(1);
expect(result[0].summary).toBe('Has summary');
});

it('should default every shaped field when examples omit them', () => {
const result = distillExamples([{}, {}]);

expect(result).toHaveLength(2);
expect(result[0]).toEqual({ id: '', name: '', summary: '', element: '', template: '' });
});

it('should fall back to the description when the summary is missing', () => {
const result = distillExamples([{ id: 'widget', element: 'nve-widget', description: 'Reusable widget' }]);

expect(result).toHaveLength(1);
expect(result[0].summary).toBe('Reusable widget');
});
});
Loading