diff --git a/packages/fiori/cypress/specs/UserMenu.cy.tsx b/packages/fiori/cypress/specs/UserMenu.cy.tsx
index f90a185634b2..e96860d933a9 100644
--- a/packages/fiori/cypress/specs/UserMenu.cy.tsx
+++ b/packages/fiori/cypress/specs/UserMenu.cy.tsx
@@ -988,3 +988,352 @@ describe("Footer configuration", () => {
cy.get("@signOutClicked").should("have.been.calledOnce");
});
});
+
+describe("UserMenuItem", () => {
+ describe("showSelection property", () => {
+ it("renders two-line layout when showSelection is true and sub-item is checked", () => {
+ cy.mount(
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ );
+
+ cy.get("[ui5-user-menu]").find("[ui5-user-menu-item][text='Theme']").as("themeItem");
+ cy.get("@themeItem")
+ .shadow()
+ .find(".ui5-user-menu-item-text-wrapper")
+ .should("exist");
+ cy.get("@themeItem")
+ .shadow()
+ .find(".ui5-user-menu-item-selection-text")
+ .should("exist")
+ .and("contain.text", "Light");
+ });
+
+ it("does not render selection text when showSelection is false", () => {
+ cy.mount(
+ <>
+
+
+
+
+
+
+
+
+ >
+ );
+
+ cy.get("[ui5-user-menu]").find("[ui5-user-menu-item][text='Settings']").as("settingsItem");
+ cy.get("@settingsItem")
+ .shadow()
+ .find(".ui5-user-menu-item-text-wrapper")
+ .should("not.exist");
+ cy.get("@settingsItem")
+ .shadow()
+ .find(".ui5-user-menu-item-selection-text")
+ .should("not.exist");
+ });
+
+ it("does not render selection text when no sub-item is checked", () => {
+ cy.mount(
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ );
+
+ cy.get("[ui5-user-menu]").find("[ui5-user-menu-item][text='Theme']").as("themeItem");
+ cy.get("@themeItem")
+ .shadow()
+ .find(".ui5-user-menu-item-text-wrapper")
+ .should("exist");
+ cy.get("@themeItem")
+ .shadow()
+ .find(".ui5-user-menu-item-selection-text")
+ .should("not.exist");
+ });
+
+ it("updates selection text when a different sub-item is checked", () => {
+ cy.mount(
+ <>
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+
+ cy.get("[ui5-user-menu]").find("[ui5-user-menu-item][text='Theme']").as("themeItem");
+ cy.get("@themeItem")
+ .shadow()
+ .find(".ui5-user-menu-item-selection-text")
+ .should("contain.text", "Light");
+
+ cy.get("@themeItem").click();
+
+ cy.get("[ui5-user-menu-item][text='Dark']").click();
+
+ cy.get("@themeItem")
+ .shadow()
+ .find(".ui5-user-menu-item-selection-text")
+ .should("contain.text", "Dark");
+ });
+ });
+
+ describe("Single-select behavior", () => {
+ it("prevents unchecking the only checked item in single-select mode", () => {
+ cy.mount(
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ );
+
+ cy.get("[ui5-user-menu]").find("[ui5-user-menu-item][text='Theme']").as("themeItem");
+ cy.get("@themeItem").click();
+
+ cy.get("[ui5-user-menu-item][text='Light']").click();
+
+ cy.get("[ui5-user-menu-item][text='Light']")
+ .should("have.attr", "checked");
+ });
+
+ it("allows unchecking in single-select mode when showSelection is false", () => {
+ cy.mount(
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ );
+
+ cy.get("[ui5-user-menu]").find("[ui5-user-menu-item][text='Options']").as("parentItem");
+ cy.get("@parentItem").click();
+
+ cy.get("[ui5-user-menu-item][text='Opt A']").click();
+
+ cy.get("[ui5-user-menu-item][text='Opt A']")
+ .should("not.have.attr", "checked");
+ });
+ });
+
+ describe("UserMenuItemGroup", () => {
+ it("renders items within a group with Single check mode", () => {
+ cy.mount(
+ <>
+
+
+
+
+
+
+
+ >
+ );
+
+ cy.get("[ui5-user-menu]").find("[ui5-user-menu-item-group]").should("exist");
+ cy.get("[ui5-user-menu-item-group]").should("have.attr", "check-mode", "Single");
+ cy.get("[ui5-user-menu-item]").should("have.length", 2);
+ });
+
+ it("renders items within a group with Multiple check mode", () => {
+ cy.mount(
+ <>
+
+
+
+
+
+
+
+
+ >
+ );
+
+ cy.get("[ui5-user-menu]").find("[ui5-user-menu-item-group]").should("exist");
+ cy.get("[ui5-user-menu-item-group]").should("have.attr", "check-mode", "Multiple");
+ cy.get("[ui5-user-menu-item]").should("have.length", 3);
+ cy.get("[ui5-user-menu-item][text='Feature A']").should("have.attr", "checked");
+ cy.get("[ui5-user-menu-item][text='Feature B']").should("have.attr", "checked");
+ cy.get("[ui5-user-menu-item][text='Feature C']").should("not.have.attr", "checked");
+ });
+
+ it("fires ui5-check event when item is checked in a group", () => {
+ cy.mount(
+ <>
+
+
+
+
+
+
+
+ >
+ );
+
+ cy.get("[ui5-user-menu]").as("userMenu");
+ cy.get("@userMenu")
+ .then($userMenu => {
+ $userMenu.get(0).addEventListener("ui5-check", cy.stub().as("checked"));
+ });
+
+ cy.get("[ui5-user-menu-item]").first().click();
+
+ cy.get("@checked").should("have.been.calledOnce");
+ });
+ });
+
+ describe("CSS styling", () => {
+ it("has show-selection attribute when showSelection is true", () => {
+ cy.mount(
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ );
+
+ cy.get("[ui5-user-menu-item][text='Theme']")
+ .should("have.attr", "show-selection");
+ });
+
+ it("does not have show-selection attribute when showSelection is false", () => {
+ cy.mount(
+ <>
+
+
+
+
+ >
+ );
+
+ cy.get("[ui5-user-menu-item][text='Settings']")
+ .should("not.have.attr", "show-selection");
+ });
+
+ it("selection text has correct styling", () => {
+ cy.mount(
+ <>
+
+
+
+
+
+
+
+
+ >
+ );
+
+ cy.get("[ui5-user-menu-item][text='Theme']")
+ .shadow()
+ .find(".ui5-user-menu-item-selection-text")
+ .should("have.css", "font-weight", "400")
+ .and("have.css", "white-space", "nowrap")
+ .and("have.css", "overflow", "hidden")
+ .and("have.css", "text-overflow", "ellipsis");
+ });
+
+ it("text wrapper has column layout with gap", () => {
+ cy.mount(
+ <>
+
+
+
+
+
+
+
+
+ >
+ );
+
+ cy.get("[ui5-user-menu-item][text='Theme']")
+ .shadow()
+ .find(".ui5-user-menu-item-text-wrapper")
+ .should("have.css", "flex-direction", "column")
+ .and("have.css", "gap", "4px");
+ });
+ });
+
+ describe("Nested submenu items", () => {
+ it("renders nested UserMenuItem hierarchy", () => {
+ cy.mount(
+ <>
+
+
+
+
+
+
+
+ >
+ );
+
+ cy.get("[ui5-user-menu]").find("[ui5-user-menu-item][text='Legal Information']").as("parentItem");
+ cy.get("@parentItem").find("[ui5-user-menu-item]").should("have.length", 2);
+ });
+
+ it("does not show selection text for non-single-select groups", () => {
+ cy.mount(
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ );
+
+ cy.get("[ui5-user-menu-item][text='Features']")
+ .shadow()
+ .find(".ui5-user-menu-item-selection-text")
+ .should("not.exist");
+ });
+ });
+});
diff --git a/packages/fiori/src/NavigationMenuItemTemplate.tsx b/packages/fiori/src/NavigationMenuItemTemplate.tsx
index 8e841eeab1be..e81be9d1fffa 100644
--- a/packages/fiori/src/NavigationMenuItemTemplate.tsx
+++ b/packages/fiori/src/NavigationMenuItemTemplate.tsx
@@ -1,17 +1,17 @@
import type NavigationMenuItem from "./NavigationMenuItem.js";
import MenuItemTemplate from "@ui5/webcomponents/dist/MenuItemTemplate.js";
+import type { MenuItemHooks } from "@ui5/webcomponents/dist/MenuItemTemplate.js";
import Icon from "@ui5/webcomponents/dist/Icon.js";
import slimArrowRightIcon from "@ui5/webcomponents-icons/dist/slim-arrow-right.js";
import arrowRightIcon from "@ui5/webcomponents-icons/dist/arrow-right.js";
-import type { ListItemHooks } from "@ui5/webcomponents/dist/ListItemTemplate.js";
-const predefinedHooks: Partial = {
+const predefinedHooks: Partial = {
listItemContent,
iconBegin,
iconEnd,
};
-export default function NavigationMenuItemTemplate(this: NavigationMenuItem, hooks?: Partial) {
+export default function NavigationMenuItemTemplate(this: NavigationMenuItem, hooks?: Partial) {
const currentHooks = { ...predefinedHooks, ...hooks, };
return <>
diff --git a/packages/fiori/src/UserMenuItem.ts b/packages/fiori/src/UserMenuItem.ts
index a384c5147bb5..856c1eef0247 100644
--- a/packages/fiori/src/UserMenuItem.ts
+++ b/packages/fiori/src/UserMenuItem.ts
@@ -1,5 +1,6 @@
-import { customElement, slotStrict as slot } from "@ui5/webcomponents-base/dist/decorators.js";
+import { customElement, slotStrict as slot, property } from "@ui5/webcomponents-base/dist/decorators.js";
import MenuItem, { isInstanceOfMenuItem } from "@ui5/webcomponents/dist/MenuItem.js";
+import MenuItemGroupCheckMode from "@ui5/webcomponents/dist/types/MenuItemGroupCheckMode.js";
import UserMenuItemTemplate from "./UserMenuItemTemplate.js";
@@ -44,9 +45,55 @@ class UserMenuItem extends MenuItem {
@slot({ "default": true, type: HTMLElement, invalidateOnChildChange: true })
declare items: DefaultSlot;
+ /**
+ * When set, a second line appears below the menu item text
+ * showing the text of the currently selected (checked) sub-item.
+ *
+ * @default false
+ * @public
+ */
+ @property({ type: Boolean })
+ showSelection = false;
+
get _menuItems() {
return this.items.filter(isInstanceOfMenuItem);
}
+
+ /**
+ * Overrides the base MenuItem behavior to prevent unchecking
+ * the currently checked item in single-select mode when
+ * the parent item uses showSelection, ensuring there is always
+ * a visible selection.
+ */
+ _updateCheckedState() {
+ const parentItem = this.parentElement?.parentElement;
+ const hasShowSelection = parentItem instanceof UserMenuItem && parentItem.showSelection;
+
+ if (hasShowSelection && this._checkMode === MenuItemGroupCheckMode.Single && this.checked) {
+ return;
+ }
+ super._updateCheckedState();
+ }
+
+ /**
+ * Returns the text of the currently checked sub-item.
+ * Only returns text for single-select groups.
+ */
+ get _selectedSubItemText(): string {
+ if (!this.showSelection) {
+ return "";
+ }
+
+ const singleSelectGroup = this._menuItemGroups.find(
+ g => g.checkMode === MenuItemGroupCheckMode.Single,
+ );
+ if (!singleSelectGroup) {
+ return "";
+ }
+
+ const checkedItem = singleSelectGroup._menuItems.find(item => item.checked);
+ return checkedItem?.text || "";
+ }
}
UserMenuItem.define();
diff --git a/packages/fiori/src/UserMenuItemTemplate.tsx b/packages/fiori/src/UserMenuItemTemplate.tsx
index 0c5b1e1d3604..ff561b1bb162 100644
--- a/packages/fiori/src/UserMenuItemTemplate.tsx
+++ b/packages/fiori/src/UserMenuItemTemplate.tsx
@@ -1,6 +1,24 @@
import type UserMenuItem from "./UserMenuItem.js";
import MenuItemTemplate from "@ui5/webcomponents/dist/MenuItemTemplate.js";
+import type { MenuItemHooks } from "@ui5/webcomponents/dist/MenuItemTemplate.js";
export default function UserMenuItemTemplate(this: UserMenuItem) {
- return [MenuItemTemplate.call(this)];
+ const hooks: Partial = {};
+
+ if (this.showSelection) {
+ hooks.menuItemTextContent = userMenuItemTextContent;
+ }
+
+ return [MenuItemTemplate.call(this, hooks)];
+}
+
+function userMenuItemTextContent(this: UserMenuItem) {
+ return (
+
+ );
}
diff --git a/packages/fiori/src/themes/UserMenuItem.css b/packages/fiori/src/themes/UserMenuItem.css
index f140dface58f..e06fb4b8a7a0 100644
--- a/packages/fiori/src/themes/UserMenuItem.css
+++ b/packages/fiori/src/themes/UserMenuItem.css
@@ -1,12 +1,46 @@
:host {
- height: 40px;
+ height: 2.5rem;
+ min-height: 2.5rem;
border: none;
}
+.ui5-li-root {
+ min-height: 2.5rem;
+}
+
:host(:last-of-type) {
- margin-bottom: 0;
+ margin-bottom: 0;
}
:host(:first-of-type) {
- margin-top: 0;
+ margin-top: 0;
+}
+
+:host([show-selection]) {
+ height: 3.25rem;
+ min-height: 3.25rem;
+}
+
+:host([show-selection]) .ui5-li-root {
+ min-height: 3.25rem;
+ padding-block: 0.5rem;
+}
+
+.ui5-user-menu-item-text-wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ overflow: hidden;
+ flex: 1;
+ min-width: 0;
+}
+
+.ui5-user-menu-item-selection-text {
+ font-family: var(--sapFontFamily);
+ font-size: var(--sapFontSize);
+ font-weight: normal;
+ color: var(--sapContent_LabelColor);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
\ No newline at end of file
diff --git a/packages/fiori/test/pages/UserMenu.html b/packages/fiori/test/pages/UserMenu.html
index 322321479a74..0b06426f9f8d 100644
--- a/packages/fiori/test/pages/UserMenu.html
+++ b/packages/fiori/test/pages/UserMenu.html
@@ -65,9 +65,9 @@
-
+
-
+
diff --git a/packages/main/src/MenuItemTemplate.tsx b/packages/main/src/MenuItemTemplate.tsx
index 6dfa99a9f82b..beab5c83fcbe 100644
--- a/packages/main/src/MenuItemTemplate.tsx
+++ b/packages/main/src/MenuItemTemplate.tsx
@@ -11,14 +11,29 @@ import Icon from "./Icon.js";
import ListItemTemplate from "./ListItemTemplate.js";
import type { ListItemHooks } from "./ListItemTemplate.js";
-const predefinedHooks: Partial = {
- listItemContent,
+export type MenuItemHooks = ListItemHooks & {
+ menuItemTextContent: (this: any) => JSX.Element;
+}
+
+const predefinedHooks: Partial = {
iconBegin,
+ menuItemTextContent,
};
-export default function MenuItemTemplate(this: MenuItem, hooks?: Partial) {
+export default function MenuItemTemplate(this: MenuItem, hooks?: Partial) {
const currentHooks = { ...predefinedHooks, ...hooks };
+ if (!hooks?.listItemContent) {
+ currentHooks.listItemContent = function listItemContent(this: MenuItem) {
+ return (<>
+ {currentHooks.menuItemTextContent!.call(this)}
+
+ {rightContent.call(this)}
+ {checkmarkContent.call(this)}
+ >);
+ };
+ }
+
return <>
{ListItemTemplate.call(this, currentHooks)}
@@ -26,13 +41,8 @@ export default function MenuItemTemplate(this: MenuItem, hooks?: Partial;
}
-function listItemContent(this: MenuItem) {
- return (<>
- {this.text && }
-
- {rightContent.call(this)}
- {checkmarkContent.call(this)}
- >);
+function menuItemTextContent(this: MenuItem) {
+ return <>{this.text && }>;
}
function checkmarkContent(this: MenuItem) {