('slot:not([name])');
+ expect(slot.assignedElements()).toEqual([content]);
+ expect(
+ labelSlot
+ .assignedNodes()
+ .map(node => node.textContent)
+ .join('')
+ ).toBe('item 1');
+
+ content.remove();
+ await elementIsStable(element);
+
+ expect(slot.assignedElements()).toEqual([]);
});
});
diff --git a/projects/core/src/menu/menu-item.ts b/projects/core/src/menu/menu-item.ts
index 0d1c84f9cb..153dc151c5 100644
--- a/projects/core/src/menu/menu-item.ts
+++ b/projects/core/src/menu/menu-item.ts
@@ -14,6 +14,7 @@ import styles from './menu-item.css?inline';
* @since 0.11.0
* @entrypoint \@nvidia-elements/core/menu
* @slot - Label and supporting content for the menu option.
+ * @slot prefix - slot for prefix icon
* @slot suffix - slot for suffix icon
* @cssprop --background
* @cssprop --border-radius
@@ -50,8 +51,9 @@ export class MenuItem extends ButtonFormControlMixin(LitElement) {
render() {
return html`
-
-
+
+
+
`;
}
diff --git a/projects/core/src/menu/menu.examples.ts b/projects/core/src/menu/menu.examples.ts
index 99f2fd75c0..2da2d97aff 100644
--- a/projects/core/src/menu/menu.examples.ts
+++ b/projects/core/src/menu/menu.examples.ts
@@ -3,6 +3,7 @@
import { html } from 'lit';
import '@nvidia-elements/core/button/define.js';
+import '@nvidia-elements/core/dot/define.js';
import '@nvidia-elements/core/dropdown/define.js';
import '@nvidia-elements/core/icon/define.js';
import '@nvidia-elements/core/menu/define.js';
@@ -15,7 +16,7 @@ import '@nvidia-elements/core/page/define.js';
export default {
title: 'Elements/Menu',
- component: 'nve-menu',
+ component: 'nve-menu'
};
/**
@@ -32,6 +33,77 @@ export const Default = {
`
};
+/**
+ * @summary Menu items with content slots for icons, dots, and other elements.
+ * @tags test-case
+ */
+export const ContentSlots = {
+ render: () => html`
+
+ Code
+ Lint
+ CLI
+ Profile
+ Access
+
+ `
+};
+
+/**
+ * @summary Dynamically toggle the compact state of a menu.
+ * @tags test-case
+ */
+export const Compact = {
+ render: () => html`
+
+
+
+
+
+
+ `
+};
+
+/**
+ * @summary An explicitly expanded group keeps navigation state controlled by the application without enabling automatic state changes. Use when the application owns expansion state.
+ */
+export const ExpandedNavigation = {
+ render: () => html`
+
+ Resources
+
+ Documentation
+ Examples
+
+
+ `
+};
+
+/**
+ * @summary A disabled menu-group header preserves its current expanded content while preventing user toggling. Use when group availability depends on application state.
+ */
+export const UnavailableNavigation = {
+ render: () => html`
+
+ Provisioning
+
+ Clusters
+ Storage
+
+
+ `
+};
+
/**
* @summary Menu with keyboard navigation and ARIA disclosure pattern inside a dropdown. Use when menu items need accessible focus management and arrow key navigation.
* @tags pattern
@@ -109,30 +181,16 @@ export const Disabled = {
`
};
-/**
- * @summary Menu items with icons to add visual context and improve usability.
- */
-export const Icons = {
- render: () => html`
-
- profile
- settings
- favorites
- logout
-
- `
-};
-
/**
* @summary Menu items with links for navigation functionality within menu structures.
*/
export const Links = {
render: () => html`
- profile
- settings
- favorites
- logout
+ profile
+ settings
+ favorites
+ logout
`
};
@@ -143,7 +201,7 @@ export const Links = {
export const Suffix = {
render: () => html`
- CMD + C
+ CopyCMD + C
`
};
@@ -195,6 +253,75 @@ export const Complex = {
`
};
+/**
+ * @summary Vertical navigation menu groups with and without icons. For deeply nested navigation use the `nve-tree` component.
+ */
+export const VerticalNavigation = {
+ render: () => html`
+
+
+
+
+
+ `
+};
+
/**
* @summary Use a navigation drawer to overlay page content for out-of-context navigation.
* @tags pattern
@@ -203,7 +330,7 @@ export const VerticalNavigationDrawer = {
render: () => html`
- NV
+ NV
NVIDIA
@@ -214,12 +341,33 @@ export const VerticalNavigationDrawer = {
Drawer
-
- item 1
- item 2
- item 3
- item 4
-
+
+ Overview
+
+ Getting Started
+ Alerts
+ Create New
+
+
+
+ Data
+
+ Catalog
+ Dashboards
+ Volumes
+ Models
+
+
+
+ Develop
+
+ Queries
+ Notebooks
+ Pipelines
+ Experiments
+ Runs
+
+
@@ -234,20 +382,41 @@ export const VerticalNavigationPanel = {
render: () => html`
- NV
+ NV
NVIDIA
-
+
Drawer
-
- item 1
- item 2
- item 3
- item 4
-
+
+ Overview
+
+ Getting Started
+ Alerts
+ Create New
+
+
+
+ Data
+
+ Catalog
+ Dashboards
+ Volumes
+ Models
+
+
+
+ Develop
+
+ Queries
+ Notebooks
+ Pipelines
+ Experiments
+ Runs
+
+
@@ -273,7 +442,7 @@ export const ItemTooltip = {
item 3
`
-}
+};
/**
* @summary Menu items with danger status styling for destructive actions like delete or logout operations.
@@ -289,4 +458,4 @@ export const DangerStatus = {
icon right
`
-}
+};
diff --git a/projects/core/src/menu/menu.global.css b/projects/core/src/menu/menu.global.css
index 9732b57fd9..f7d45c681c 100644
--- a/projects/core/src/menu/menu.global.css
+++ b/projects/core/src/menu/menu.global.css
@@ -9,3 +9,17 @@ nve-menu:has(nve-tooltip),
nve-menu:has(nve-tooltip) nve-menu-item {
content-visibility: initial;
}
+
+nve-menu[density='compact'] {
+ --min-width: 0;
+
+ nve-menu-item {
+ --gap: 0;
+ width: fit-content;
+ }
+
+ nve-menu-item::part(_slot),
+ nve-menu-item::part(_suffix) {
+ display: none !important;
+ }
+}
diff --git a/projects/core/src/menu/menu.test.lighthouse.ts b/projects/core/src/menu/menu.test.lighthouse.ts
index 0a50e54f72..b71f24bf21 100644
--- a/projects/core/src/menu/menu.test.lighthouse.ts
+++ b/projects/core/src/menu/menu.test.lighthouse.ts
@@ -24,6 +24,6 @@ describe('menu 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(16.33);
+ expect(report.payload.javascript.kb).toBeLessThan(23.5);
});
});
diff --git a/projects/core/src/menu/menu.test.ts b/projects/core/src/menu/menu.test.ts
index 7fdc36006e..7cd3b46501 100644
--- a/projects/core/src/menu/menu.test.ts
+++ b/projects/core/src/menu/menu.test.ts
@@ -36,6 +36,35 @@ describe(Menu.metadata.tag, () => {
expect(element._internals.role).toBe('menu');
});
+ it('should leave density unset by default', () => {
+ expect(element.density).toBeUndefined();
+ expect(element.hasAttribute('density')).toBe(false);
+ });
+
+ it.each(['compact', 'default'] as const)('should reflect density %s to an attribute', async density => {
+ element.density = density;
+ await elementIsStable(element);
+
+ expect(element.getAttribute('density')).toBe(density);
+ });
+
+ it.each(['compact', 'default'] as const)('should update density from attribute %s', async density => {
+ element.setAttribute('density', density);
+ await elementIsStable(element);
+
+ expect(element.density).toBe(density);
+ });
+
+ it('should remove the density attribute when the property is cleared', async () => {
+ element.density = 'compact';
+ await elementIsStable(element);
+
+ element.density = undefined;
+ await elementIsStable(element);
+
+ expect(element.hasAttribute('density')).toBe(false);
+ });
+
it('should navigate between items with ArrowDown key', async () => {
await elementIsStable(element);
const items = element.querySelectorAll('nve-menu-item');
@@ -71,6 +100,117 @@ describe(Menu.metadata.tag, () => {
});
});
+describe(`${Menu.metadata.tag}: density and item slots`, () => {
+ let fixture: HTMLElement;
+ let element: Menu;
+ let prefixSlot: HTMLSlotElement;
+ let labelSlot: HTMLSlotElement;
+ let suffixSlot: HTMLSlotElement;
+
+ beforeEach(async () => {
+ fixture = await createFixture(html`
+
+
+ prefix
+ label
+ suffix
+
+
+ `);
+ element = fixture.querySelector(Menu.metadata.tag);
+ await elementIsStable(element);
+ const item = element.querySelector('nve-menu-item');
+ await elementIsStable(item);
+ prefixSlot = item.shadowRoot.querySelector('slot[name="prefix"]');
+ labelSlot = item.shadowRoot.querySelector('slot:not([name])');
+ suffixSlot = item.shadowRoot.querySelector('slot[name="suffix"]');
+ });
+
+ afterEach(() => {
+ removeFixture(fixture);
+ });
+
+ it('should display the prefix, label, and suffix when density is unset', () => {
+ expect(getComputedStyle(prefixSlot).display).not.toBe('none');
+ expect(getComputedStyle(labelSlot).display).not.toBe('none');
+ expect(getComputedStyle(suffixSlot).display).not.toBe('none');
+ });
+
+ it('should hide the label and suffix while keeping the prefix visible in compact density', async () => {
+ element.density = 'compact';
+ await elementIsStable(element);
+
+ expect(getComputedStyle(prefixSlot).display).not.toBe('none');
+ expect(getComputedStyle(labelSlot).display).toBe('none');
+ expect(getComputedStyle(suffixSlot).display).toBe('none');
+ });
+
+ it.each(['default', undefined] as const)(
+ 'should restore the label and suffix when density becomes %s',
+ async density => {
+ element.density = 'compact';
+ await elementIsStable(element);
+ expect(getComputedStyle(labelSlot).display).toBe('none');
+ expect(getComputedStyle(suffixSlot).display).toBe('none');
+
+ element.density = density;
+ await elementIsStable(element);
+
+ expect(getComputedStyle(prefixSlot).display).not.toBe('none');
+ expect(getComputedStyle(labelSlot).display).not.toBe('none');
+ expect(getComputedStyle(suffixSlot).display).not.toBe('none');
+ }
+ );
+});
+
+describe(`${Menu.metadata.tag}: menu group slot`, () => {
+ let fixture: HTMLElement;
+ let element: Menu;
+
+ beforeEach(async () => {
+ fixture = await createFixture(html`
+
+ item
+
+ `);
+ element = fixture.querySelector(Menu.metadata.tag);
+ await elementIsStable(element);
+ });
+
+ afterEach(() => {
+ removeFixture(fixture);
+ });
+
+ it('should clear an automatically assigned menu slot on disconnect', () => {
+ expect(element.slot).toBe('menu');
+ element.remove();
+ expect(element.slot).toBe('');
+
+ fixture.querySelector('nve-menu-group').append(element);
+ expect(element.slot).toBe('menu');
+ element.remove();
+ expect(element.slot).toBe('');
+ });
+
+ it('should preserve an authored menu slot on disconnect', () => {
+ element.remove();
+ const authoredGroup = document.createElement('nve-menu-group');
+ element = document.createElement(Menu.metadata.tag);
+ element.slot = 'menu';
+ authoredGroup.append(element);
+ fixture.append(authoredGroup);
+
+ element.remove();
+ expect(element.slot).toBe('menu');
+ });
+
+ it('should preserve a changed slot on disconnect', () => {
+ element.slot = 'other';
+ element.remove();
+ expect(element.slot).toBe('other');
+ });
+});
+
describe(`${Menu.metadata.tag}: scroll event`, () => {
let fixture: HTMLElement;
let element: Menu;
diff --git a/projects/core/src/menu/menu.ts b/projects/core/src/menu/menu.ts
index 137dc76be5..1504f51241 100644
--- a/projects/core/src/menu/menu.ts
+++ b/projects/core/src/menu/menu.ts
@@ -2,8 +2,9 @@
// SPDX-License-Identifier: Apache-2.0
import { html, LitElement } from 'lit';
+import { property } from 'lit/decorators/property.js';
import { queryAssignedElements } from 'lit/decorators/query-assigned-elements.js';
-import type { KeynavListConfig } from '@nvidia-elements/core/internal';
+import type { Density, KeynavListConfig } from '@nvidia-elements/core/internal';
import {
useStyles,
attachInternals,
@@ -44,6 +45,11 @@ export class Menu extends LitElement {
static elementDefinitions = {};
+ /**
+ * Determines if the menu is in a compact layout. This hides all menu item content except the prefix icon slot.
+ */
+ @property({ type: String, reflect: true }) density?: Density;
+
/** @private */
get keynavListConfig(): KeynavListConfig {
return {
@@ -58,6 +64,7 @@ export class Menu extends LitElement {
@queryAssignedElements() private items!: MenuItem[];
#scrollRAF: number | null = null;
+ #assignedMenuSlot = false;
#handleScroll = () => {
if (this.#scrollRAF !== null) return;
@@ -96,5 +103,18 @@ export class Menu extends LitElement {
attachInternals(this);
this._internals.role = 'menu';
appendRootNodeStyle(this, globalStyles);
+
+ if (this.parentElement?.localName === 'nve-menu-group' && !this.slot) {
+ this.slot = 'menu';
+ this.#assignedMenuSlot = true;
+ }
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ if (this.#assignedMenuSlot && this.slot === 'menu') {
+ this.slot = '';
+ }
+ this.#assignedMenuSlot = false;
}
}
diff --git a/projects/core/src/select/select.test.lighthouse.ts b/projects/core/src/select/select.test.lighthouse.ts
index 4fbb23edcf..82e1b40408 100644
--- a/projects/core/src/select/select.test.lighthouse.ts
+++ b/projects/core/src/select/select.test.lighthouse.ts
@@ -26,6 +26,6 @@ describe('select 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(35.31);
+ expect(report.payload.javascript.kb).toBeLessThan(35.6);
});
});
diff --git a/projects/core/src/steps/steps.ts b/projects/core/src/steps/steps.ts
index 1f4a1ddf75..f552df3d7c 100644
--- a/projects/core/src/steps/steps.ts
+++ b/projects/core/src/steps/steps.ts
@@ -8,7 +8,7 @@ import { state } from 'lit/decorators/state.js';
import { when } from 'lit/directives/when.js';
import { queryAssignedElements } from 'lit/decorators/query-assigned-elements.js';
import { ButtonFormControlMixin } from '@nvidia-elements/forms/mixins';
-import type { KeynavListConfig, Container } from '@nvidia-elements/core/internal';
+import type { KeynavListConfig } from '@nvidia-elements/core/internal';
import {
stateSelected,
useStyles,
@@ -58,7 +58,7 @@ export class StepsItem extends ButtonFormControlMixin(LitElement) {
/**
* Determines whether the steps should display in condensed format with no text labels.
*/
- @property({ type: String, reflect: true }) container?: Extract;
+ @property({ type: String, reflect: true }) container?: 'condensed';
/** @private */
@state() index: number = 0;
@@ -122,7 +122,7 @@ export class Steps extends LitElement {
/**
* Determines whether the steps should display in condensed format with no text labels.
*/
- @property({ type: String, reflect: true }) container?: Extract;
+ @property({ type: String, reflect: true }) container?: 'condensed';
/**
* Determines whether the steps should handle selection behavior vs. defaults to off.
diff --git a/projects/internals/metadata/static/tests.json b/projects/internals/metadata/static/tests.json
index 7048a73258..7eede6f5c3 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:003bd0e158bc1fe3dce72b7ef9b0fc7da1d2fbc55a26dcf2b3c3732b979b12d9
-size 2962409
+oid sha256:ce71d876f17db545219fbb9699b717b944774b1839fcc2aae481684d11f84cd4
+size 2947869
diff --git a/projects/lint/src/eslint/internals/slotted-elements.ts b/projects/lint/src/eslint/internals/slotted-elements.ts
index e69686ef82..68b41befa2 100644
--- a/projects/lint/src/eslint/internals/slotted-elements.ts
+++ b/projects/lint/src/eslint/internals/slotted-elements.ts
@@ -23,6 +23,7 @@ export const SLOTTED_ELEMENT_CONTRACTS = {
'nve-grid-header': ['nve-grid-column'],
'nve-grid-row': ['nve-grid-cell'],
'nve-menu': ['nve-menu-item', 'nve-divider'],
+ 'nve-menu-group': ['nve-menu'],
'nve-notification-group': ['nve-notification'],
'nve-radio': ['label', 'input', 'nve-control-message'],
'nve-radio-group': ['label', 'nve-control-message', 'nve-radio'],
diff --git a/projects/lint/src/eslint/rules/no-invalid-slotted-elements.test.ts b/projects/lint/src/eslint/rules/no-invalid-slotted-elements.test.ts
index b54b268a5d..1bb54b2561 100644
--- a/projects/lint/src/eslint/rules/no-invalid-slotted-elements.test.ts
+++ b/projects/lint/src/eslint/rules/no-invalid-slotted-elements.test.ts
@@ -133,6 +133,15 @@ describe('noInvalidSlottedElements', () => {
}
]
},
+ {
+ code: '',
+ errors: [
+ {
+ messageId: 'invalid-slotted-element',
+ data: { allowed: 'nve-menu', child: 'nve-menu-item', parent: 'nve-menu-group' }
+ }
+ ]
+ },
{
code: '',
errors: [
diff --git a/projects/site/src/docs/elements/menu.md b/projects/site/src/docs/elements/menu.md
index 5184d257cb..adc5dd6362 100644
--- a/projects/site/src/docs/elements/menu.md
+++ b/projects/site/src/docs/elements/menu.md
@@ -4,7 +4,7 @@
description: 'Use NVIDIA Elements menus for accessible navigation and actions with keyboard controls, selected states, links, icons, scrolling, and nested dropdown patterns.',
layout: 'docs.11ty.js',
tag: 'nve-menu',
- associatedElements: ['nve-menu-item']
+ associatedElements: ['nve-menu-group', 'nve-menu-item']
}
---
@@ -24,19 +24,15 @@
{% example '@nvidia-elements/core/menu/menu.examples.json' 'Current' %}
-## Border Background
-
-{% example '@nvidia-elements/core/menu/menu.examples.json' 'BorderBackground' %}
-
## Disabled
{% api 'nve-menu-item', 'property', 'disabled' %}
{% example '@nvidia-elements/core/menu/menu.examples.json' 'Disabled' %}
-## Icons
+## Content Slots
-{% example '@nvidia-elements/core/menu/menu.examples.json' 'Icons' %}
+{% example '@nvidia-elements/core/menu/menu.examples.json' 'ContentSlots' %}
## Scroll
@@ -44,7 +40,11 @@
## Dropdown
-{% example '@nvidia-elements/core/menu/menu.examples.json' 'Dropdown' '{ "inline": false, "height": "380px" }' %}
+{% example '@nvidia-elements/core/menu/menu.examples.json' 'Dropdown' '{ "inline": false, "height": "400px" }' %}
+
+## Vertical Navigation
+
+{% example '@nvidia-elements/core/menu/menu.examples.json' 'VerticalNavigation' %}
## Vertical Navigation Drawer
@@ -54,6 +54,10 @@
{% example '@nvidia-elements/core/menu/menu.examples.json' 'VerticalNavigationPanel' %}
+## Compact
+
+{% example '@nvidia-elements/core/menu/menu.examples.json' 'Compact' %}
+
## Menu Item Tooltip
{% example '@nvidia-elements/core/menu/menu.examples.json' 'ItemTooltip' %}
@@ -73,3 +77,11 @@
## Danger Status
{% example '@nvidia-elements/core/menu/menu.examples.json' 'DangerStatus' %}
+
+## Expanded Navigation
+
+{% example '@nvidia-elements/core/menu/menu.examples.json' 'ExpandedNavigation' %}
+
+## Border Background
+
+{% example '@nvidia-elements/core/menu/menu.examples.json' 'BorderBackground' %}
diff --git a/projects/starters/angular/package.json b/projects/starters/angular/package.json
index 3d2edd218b..4335966d01 100644
--- a/projects/starters/angular/package.json
+++ b/projects/starters/angular/package.json
@@ -85,19 +85,19 @@
"@nvidia-elements/monaco": "workspace:*",
"@nvidia-elements/styles": "workspace:*",
"@nvidia-elements/themes": "workspace:*",
- "@angular/common": "22.1.7",
- "@angular/compiler": "22.1.7",
- "@angular/core": "22.1.7",
- "@angular/forms": "22.1.7",
- "@angular/platform-browser": "22.1.7",
- "@angular/router": "22.1.7",
+ "@angular/common": "22.2.0",
+ "@angular/compiler": "22.2.0",
+ "@angular/core": "22.2.0",
+ "@angular/forms": "22.2.0",
+ "@angular/platform-browser": "22.2.0",
+ "@angular/router": "22.2.0",
"rxjs": "7.8.2",
"tslib": "2.8.1"
},
"devDependencies": {
"@angular/build": "22.1.7",
"@angular/cli": "22.1.7",
- "@angular/compiler-cli": "22.1.7",
+ "@angular/compiler-cli": "22.2.0",
"@nvidia-elements/lint": "workspace:*",
"eslint": "catalog:",
"jsdom": "30.0.1",