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 ( +
+ {this.text &&
{this.text}
} + {this._selectedSubItemText && +
{this._selectedSubItemText}
+ } +
+ ); } 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 &&
{this.text}
} - - {rightContent.call(this)} - {checkmarkContent.call(this)} - ); +function menuItemTextContent(this: MenuItem) { + return <>{this.text &&
{this.text}
}; } function checkmarkContent(this: MenuItem) {