From 800385637d853d46f9aa807782b7045fc24985d1 Mon Sep 17 00:00:00 2001 From: Svilen Darvenyashki Date: Mon, 30 Mar 2026 09:40:58 +0300 Subject: [PATCH 1/7] wip: user-menu-item feature in progress --- packages/fiori/src/UserMenuItem.ts | 33 ++++++++++++++++++- packages/fiori/src/UserMenuItemTemplate.tsx | 20 +++++++++++- packages/fiori/src/themes/UserMenuItem.css | 35 +++++++++++++++++++++ packages/fiori/test/pages/UserMenu.html | 4 +-- packages/main/src/MenuItem.ts | 27 ++++++++++++++-- packages/main/src/MenuItemGroup.ts | 5 ++- packages/main/src/MenuItemTemplate.tsx | 28 +++++++++++++++-- 7 files changed, 143 insertions(+), 9 deletions(-) diff --git a/packages/fiori/src/UserMenuItem.ts b/packages/fiori/src/UserMenuItem.ts index a384c5147bb5..d46724f69aa3 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,39 @@ 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 }) + showSelectionText = false; + get _menuItems() { return this.items.filter(isInstanceOfMenuItem); } + + /** + * Returns the text of the currently checked sub-item. + * Only returns text for single-select groups. + */ + get _selectedSubItemText(): string { + if (!this.showSelectionText) { + 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..8a0c5466e6e4 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 menuItemHooks: Partial = {}; + + if (this.showSelectionText) { + menuItemHooks.menuItemTextContent = userMenuItemTextContent; + } + + return [MenuItemTemplate.call(this, undefined, menuItemHooks)]; +} + +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..bfe21832d728 100644 --- a/packages/fiori/src/themes/UserMenuItem.css +++ b/packages/fiori/src/themes/UserMenuItem.css @@ -1,12 +1,47 @@ :host { height: 40px; + min-height: 40px; border: none; } +/* Ensure inner li matches host height for proper focus outline */ +.ui5-li-root { + min-height: 40px; +} + :host(:last-of-type) { margin-bottom: 0; } :host(:first-of-type) { margin-top: 0; +} + +/* Allow taller items when showing selection text */ +:host([show-selection-text]) { + height: auto; + min-height: 40px; +} + +/* Wrapper for two-line layout (text + selected sub-item) */ +.ui5-user-menu-item-text-wrapper { + display: flex; + flex-direction: column; + overflow: hidden; + flex: 1; + min-width: 0; +} + +/* Second line showing selected sub-item text */ +.ui5-user-menu-item-selection-text { + font-size: var(--sapFontSmallSize); + color: var(--sapContent_LabelColor); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Checkmark spacing in sub-menu popover: 2rem gap between text and checkmark */ +.ui5-menu-item-checked { + padding-inline-start: 2rem; } \ No newline at end of file diff --git a/packages/fiori/test/pages/UserMenu.html b/packages/fiori/test/pages/UserMenu.html index 322321479a74..02f5bc7755f5 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/MenuItem.ts b/packages/main/src/MenuItem.ts index c8b2b9654c5b..0f7fa8e82d79 100644 --- a/packages/main/src/MenuItem.ts +++ b/packages/main/src/MenuItem.ts @@ -282,6 +282,20 @@ class MenuItem extends ListItem implements IMenuItem { @property() _checkMode: `${MenuItemGroupCheckMode}` = "None"; + /** + * Defines the position of the item within its group. + * @private + */ + @property({ type: Number, noAttribute: true }) + _posinset?: number; + + /** + * Defines the total number of items in the group. + * @private + */ + @property({ type: Number, noAttribute: true }) + _setsize?: number; + /** * Defines the items of this component. * @@ -484,10 +498,19 @@ class MenuItem extends ListItem implements IMenuItem { ariaKeyShortcuts: this.accessibilityAttributes.ariaKeyShortcuts, ariaExpanded: this.hasSubmenu ? this.isSubMenuOpen : undefined, ariaHidden: !!this.additionalText && !!this.accessibilityAttributes.ariaKeyShortcuts ? true : undefined, - ariaChecked: this._markChecked ? true : undefined, + ariaChecked: this._checkMode !== MenuItemGroupCheckMode.None ? this.checked : undefined, }; - return { ...super._accInfo, ...accInfoSettings }; + const result = { ...super._accInfo, ...accInfoSettings }; + + if (this._posinset !== undefined) { + result.posinset = this._posinset; + } + if (this._setsize !== undefined) { + result.setsize = this._setsize; + } + + return result; } get _popover() { diff --git a/packages/main/src/MenuItemGroup.ts b/packages/main/src/MenuItemGroup.ts index f86009808202..419b6eff6ade 100644 --- a/packages/main/src/MenuItemGroup.ts +++ b/packages/main/src/MenuItemGroup.ts @@ -108,8 +108,11 @@ class MenuItemGroup extends UI5Element implements IMenuItem { * @private */ _updateItemsCheckMode() { - this._menuItems.forEach((item: MenuItem) => { + const menuItems = this._menuItems; + menuItems.forEach((item: MenuItem, index: number) => { item._checkMode = this.checkMode; + item._posinset = index + 1; + item._setsize = menuItems.length; }); } diff --git a/packages/main/src/MenuItemTemplate.tsx b/packages/main/src/MenuItemTemplate.tsx index 6dfa99a9f82b..17a08d0ceba3 100644 --- a/packages/main/src/MenuItemTemplate.tsx +++ b/packages/main/src/MenuItemTemplate.tsx @@ -11,13 +11,33 @@ import Icon from "./Icon.js"; import ListItemTemplate from "./ListItemTemplate.js"; import type { ListItemHooks } from "./ListItemTemplate.js"; +export type MenuItemHooks = { + menuItemTextContent: (this: any) => JSX.Element; +} + +const predefinedMenuItemHooks: MenuItemHooks = { + menuItemTextContent, +}; + const predefinedHooks: Partial = { listItemContent, iconBegin, }; -export default function MenuItemTemplate(this: MenuItem, hooks?: Partial) { +export default function MenuItemTemplate(this: MenuItem, hooks?: Partial, menuItemHooks?: Partial) { const currentHooks = { ...predefinedHooks, ...hooks }; + const currentMenuItemHooks = { ...predefinedMenuItemHooks, ...menuItemHooks }; + + if (!hooks?.listItemContent) { + currentHooks.listItemContent = function(this: MenuItem) { + return (<> + {currentMenuItemHooks.menuItemTextContent.call(this)} + + {rightContent.call(this)} + {checkmarkContent.call(this)} + ); + }; + } return <> {ListItemTemplate.call(this, currentHooks)} @@ -28,13 +48,17 @@ export default function MenuItemTemplate(this: MenuItem, hooks?: Partial - {this.text &&
{this.text}
} + {menuItemTextContent.call(this)} {rightContent.call(this)} {checkmarkContent.call(this)} ); } +function menuItemTextContent(this: MenuItem) { + return <>{this.text &&
{this.text}
}; +} + function checkmarkContent(this: MenuItem) { return !this._markChecked ? "" : (
From d90f9366f213f19d65fa770f4f79f166bb27bc2f Mon Sep 17 00:00:00 2001 From: Svilen Darvenyashki Date: Tue, 31 Mar 2026 00:24:16 +0300 Subject: [PATCH 2/7] chore(user-menu-item): enhance accessibility --- packages/fiori/src/UserMenuItem.ts | 14 ++++++++++++++ packages/fiori/src/UserMenuItemTemplate.tsx | 2 +- packages/fiori/src/themes/UserMenuItem.css | 2 +- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/fiori/src/UserMenuItem.ts b/packages/fiori/src/UserMenuItem.ts index d46724f69aa3..391a6752542a 100644 --- a/packages/fiori/src/UserMenuItem.ts +++ b/packages/fiori/src/UserMenuItem.ts @@ -45,6 +45,20 @@ class UserMenuItem extends MenuItem { @slot({ "default": true, type: HTMLElement, invalidateOnChildChange: true }) declare items: DefaultSlot; + get _accessibleNameRef(): string { + return `${this._id}-menu-item-text`; + } + + get _accInfo() { + const result = { ...super._accInfo }; + + if (this.hasSubmenu) { + result.ariaOwns = `${this._id}-menu-list`; + } + + return result; + } + /** * When set, a second line appears below the menu item text * showing the text of the currently selected (checked) sub-item. diff --git a/packages/fiori/src/UserMenuItemTemplate.tsx b/packages/fiori/src/UserMenuItemTemplate.tsx index 8a0c5466e6e4..d9afa1e0da50 100644 --- a/packages/fiori/src/UserMenuItemTemplate.tsx +++ b/packages/fiori/src/UserMenuItemTemplate.tsx @@ -14,7 +14,7 @@ export default function UserMenuItemTemplate(this: UserMenuItem) { 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 bfe21832d728..b552b7780f87 100644 --- a/packages/fiori/src/themes/UserMenuItem.css +++ b/packages/fiori/src/themes/UserMenuItem.css @@ -34,7 +34,7 @@ /* Second line showing selected sub-item text */ .ui5-user-menu-item-selection-text { - font-size: var(--sapFontSmallSize); + font-size: var(--sapFontSize); color: var(--sapContent_LabelColor); white-space: nowrap; overflow: hidden; From 35f6b35e3c93170e25ec642ef3ed8c97b23d6aff Mon Sep 17 00:00:00 2001 From: Svilen Darvenyashki Date: Tue, 31 Mar 2026 00:41:50 +0300 Subject: [PATCH 3/7] chore(ui5-user-menu-item): reverts changes on MenuItem and MenuItemGroup --- packages/main/src/MenuItem.ts | 27 ++------------------------- packages/main/src/MenuItemGroup.ts | 5 +---- 2 files changed, 3 insertions(+), 29 deletions(-) diff --git a/packages/main/src/MenuItem.ts b/packages/main/src/MenuItem.ts index 0f7fa8e82d79..c8b2b9654c5b 100644 --- a/packages/main/src/MenuItem.ts +++ b/packages/main/src/MenuItem.ts @@ -282,20 +282,6 @@ class MenuItem extends ListItem implements IMenuItem { @property() _checkMode: `${MenuItemGroupCheckMode}` = "None"; - /** - * Defines the position of the item within its group. - * @private - */ - @property({ type: Number, noAttribute: true }) - _posinset?: number; - - /** - * Defines the total number of items in the group. - * @private - */ - @property({ type: Number, noAttribute: true }) - _setsize?: number; - /** * Defines the items of this component. * @@ -498,19 +484,10 @@ class MenuItem extends ListItem implements IMenuItem { ariaKeyShortcuts: this.accessibilityAttributes.ariaKeyShortcuts, ariaExpanded: this.hasSubmenu ? this.isSubMenuOpen : undefined, ariaHidden: !!this.additionalText && !!this.accessibilityAttributes.ariaKeyShortcuts ? true : undefined, - ariaChecked: this._checkMode !== MenuItemGroupCheckMode.None ? this.checked : undefined, + ariaChecked: this._markChecked ? true : undefined, }; - const result = { ...super._accInfo, ...accInfoSettings }; - - if (this._posinset !== undefined) { - result.posinset = this._posinset; - } - if (this._setsize !== undefined) { - result.setsize = this._setsize; - } - - return result; + return { ...super._accInfo, ...accInfoSettings }; } get _popover() { diff --git a/packages/main/src/MenuItemGroup.ts b/packages/main/src/MenuItemGroup.ts index 419b6eff6ade..f86009808202 100644 --- a/packages/main/src/MenuItemGroup.ts +++ b/packages/main/src/MenuItemGroup.ts @@ -108,11 +108,8 @@ class MenuItemGroup extends UI5Element implements IMenuItem { * @private */ _updateItemsCheckMode() { - const menuItems = this._menuItems; - menuItems.forEach((item: MenuItem, index: number) => { + this._menuItems.forEach((item: MenuItem) => { item._checkMode = this.checkMode; - item._posinset = index + 1; - item._setsize = menuItems.length; }); } From e22202c42eeb2a22f07a280b2672d98675906d64 Mon Sep 17 00:00:00 2001 From: Svilen Darvenyashki Date: Tue, 31 Mar 2026 16:17:46 +0300 Subject: [PATCH 4/7] chore(ui5-user-menu-item): improve hook handling --- .../fiori/src/NavigationMenuItemTemplate.tsx | 6 ++--- packages/fiori/src/UserMenuItem.ts | 18 ++------------ packages/fiori/src/UserMenuItemTemplate.tsx | 10 ++++---- packages/fiori/test/pages/UserMenu.html | 2 +- packages/main/src/MenuItemTemplate.tsx | 24 ++++--------------- 5 files changed, 16 insertions(+), 44 deletions(-) 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 391a6752542a..81402fed7a3e 100644 --- a/packages/fiori/src/UserMenuItem.ts +++ b/packages/fiori/src/UserMenuItem.ts @@ -45,20 +45,6 @@ class UserMenuItem extends MenuItem { @slot({ "default": true, type: HTMLElement, invalidateOnChildChange: true }) declare items: DefaultSlot; - get _accessibleNameRef(): string { - return `${this._id}-menu-item-text`; - } - - get _accInfo() { - const result = { ...super._accInfo }; - - if (this.hasSubmenu) { - result.ariaOwns = `${this._id}-menu-list`; - } - - return result; - } - /** * When set, a second line appears below the menu item text * showing the text of the currently selected (checked) sub-item. @@ -67,7 +53,7 @@ class UserMenuItem extends MenuItem { * @public */ @property({ type: Boolean }) - showSelectionText = false; + showSelection = false; get _menuItems() { return this.items.filter(isInstanceOfMenuItem); @@ -78,7 +64,7 @@ class UserMenuItem extends MenuItem { * Only returns text for single-select groups. */ get _selectedSubItemText(): string { - if (!this.showSelectionText) { + if (!this.showSelection) { return ""; } diff --git a/packages/fiori/src/UserMenuItemTemplate.tsx b/packages/fiori/src/UserMenuItemTemplate.tsx index d9afa1e0da50..ff561b1bb162 100644 --- a/packages/fiori/src/UserMenuItemTemplate.tsx +++ b/packages/fiori/src/UserMenuItemTemplate.tsx @@ -3,18 +3,18 @@ import MenuItemTemplate from "@ui5/webcomponents/dist/MenuItemTemplate.js"; import type { MenuItemHooks } from "@ui5/webcomponents/dist/MenuItemTemplate.js"; export default function UserMenuItemTemplate(this: UserMenuItem) { - const menuItemHooks: Partial = {}; + const hooks: Partial = {}; - if (this.showSelectionText) { - menuItemHooks.menuItemTextContent = userMenuItemTextContent; + if (this.showSelection) { + hooks.menuItemTextContent = userMenuItemTextContent; } - return [MenuItemTemplate.call(this, undefined, menuItemHooks)]; + return [MenuItemTemplate.call(this, hooks)]; } function userMenuItemTextContent(this: UserMenuItem) { return ( -
+
{this.text &&
{this.text}
} {this._selectedSubItemText &&
{this._selectedSubItemText}
diff --git a/packages/fiori/test/pages/UserMenu.html b/packages/fiori/test/pages/UserMenu.html index 02f5bc7755f5..0b06426f9f8d 100644 --- a/packages/fiori/test/pages/UserMenu.html +++ b/packages/fiori/test/pages/UserMenu.html @@ -65,7 +65,7 @@ - + diff --git a/packages/main/src/MenuItemTemplate.tsx b/packages/main/src/MenuItemTemplate.tsx index 17a08d0ceba3..5424d503978d 100644 --- a/packages/main/src/MenuItemTemplate.tsx +++ b/packages/main/src/MenuItemTemplate.tsx @@ -11,27 +11,22 @@ import Icon from "./Icon.js"; import ListItemTemplate from "./ListItemTemplate.js"; import type { ListItemHooks } from "./ListItemTemplate.js"; -export type MenuItemHooks = { +export type MenuItemHooks = ListItemHooks & { menuItemTextContent: (this: any) => JSX.Element; } -const predefinedMenuItemHooks: MenuItemHooks = { - menuItemTextContent, -}; - -const predefinedHooks: Partial = { - listItemContent, +const predefinedHooks: Partial = { iconBegin, + menuItemTextContent, }; -export default function MenuItemTemplate(this: MenuItem, hooks?: Partial, menuItemHooks?: Partial) { +export default function MenuItemTemplate(this: MenuItem, hooks?: Partial) { const currentHooks = { ...predefinedHooks, ...hooks }; - const currentMenuItemHooks = { ...predefinedMenuItemHooks, ...menuItemHooks }; if (!hooks?.listItemContent) { currentHooks.listItemContent = function(this: MenuItem) { return (<> - {currentMenuItemHooks.menuItemTextContent.call(this)} + {currentHooks.menuItemTextContent!.call(this)} {rightContent.call(this)} {checkmarkContent.call(this)} @@ -46,15 +41,6 @@ export default function MenuItemTemplate(this: MenuItem, hooks?: Partial; } -function listItemContent(this: MenuItem) { - return (<> - {menuItemTextContent.call(this)} - - {rightContent.call(this)} - {checkmarkContent.call(this)} - ); -} - function menuItemTextContent(this: MenuItem) { return <>{this.text &&
{this.text}
}; } From b8df9f7e7a667230cae58b340a3ed9ac7787e38b Mon Sep 17 00:00:00 2001 From: Svilen Darvenyashki Date: Thu, 16 Apr 2026 13:53:14 +0300 Subject: [PATCH 5/7] chore(UserMenuItem): make submenu selection not removable --- packages/fiori/src/UserMenuItem.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/fiori/src/UserMenuItem.ts b/packages/fiori/src/UserMenuItem.ts index 81402fed7a3e..1daf4e206b84 100644 --- a/packages/fiori/src/UserMenuItem.ts +++ b/packages/fiori/src/UserMenuItem.ts @@ -59,6 +59,18 @@ class UserMenuItem extends MenuItem { return this.items.filter(isInstanceOfMenuItem); } + /** + * Overrides the base MenuItem behavior to prevent unchecking + * the currently checked item in single-select mode, + * ensuring there is always a selection. + */ + _updateCheckedState() { + if (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. From ed06865e60d4d2482dba18e268b45c956b26566e Mon Sep 17 00:00:00 2001 From: Svilen Darvenyashki Date: Mon, 27 Apr 2026 14:42:05 +0300 Subject: [PATCH 6/7] fix(ui5-user-menu-item): fix lint error, add selection line CSS and tests - Fix ESLint func-names/space-before-function-paren in MenuItemTemplate - Update UserMenuItem CSS: 3.25rem height for two-line selection display, 0.5rem padding, 0.25rem gap, proper font styling with SAP theme vars - Fix CSS selector from [show-selection-text] to [show-selection] - Add 17 Cypress tests for showSelection, single-select behavior, UserMenuItemGroup, CSS styling, and nested submenu items --- packages/fiori/cypress/specs/UserMenu.cy.tsx | 325 +++++++++++++++++++ packages/fiori/src/themes/UserMenuItem.css | 53 ++- packages/main/src/MenuItemTemplate.tsx | 2 +- 3 files changed, 352 insertions(+), 28 deletions(-) diff --git a/packages/fiori/cypress/specs/UserMenu.cy.tsx b/packages/fiori/cypress/specs/UserMenu.cy.tsx index f90a185634b2..f765956ba09b 100644 --- a/packages/fiori/cypress/specs/UserMenu.cy.tsx +++ b/packages/fiori/cypress/specs/UserMenu.cy.tsx @@ -988,3 +988,328 @@ 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"); + }); + }); + + 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/themes/UserMenuItem.css b/packages/fiori/src/themes/UserMenuItem.css index b552b7780f87..e06fb4b8a7a0 100644 --- a/packages/fiori/src/themes/UserMenuItem.css +++ b/packages/fiori/src/themes/UserMenuItem.css @@ -1,47 +1,46 @@ :host { - height: 40px; - min-height: 40px; + height: 2.5rem; + min-height: 2.5rem; border: none; } -/* Ensure inner li matches host height for proper focus outline */ .ui5-li-root { - min-height: 40px; + min-height: 2.5rem; } :host(:last-of-type) { - margin-bottom: 0; + margin-bottom: 0; } :host(:first-of-type) { - margin-top: 0; + margin-top: 0; } -/* Allow taller items when showing selection text */ -:host([show-selection-text]) { - height: auto; - min-height: 40px; +:host([show-selection]) { + height: 3.25rem; + min-height: 3.25rem; } -/* Wrapper for two-line layout (text + selected sub-item) */ -.ui5-user-menu-item-text-wrapper { - display: flex; - flex-direction: column; - overflow: hidden; - flex: 1; - min-width: 0; +:host([show-selection]) .ui5-li-root { + min-height: 3.25rem; + padding-block: 0.5rem; } -/* Second line showing selected sub-item text */ -.ui5-user-menu-item-selection-text { - font-size: var(--sapFontSize); - color: var(--sapContent_LabelColor); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; +.ui5-user-menu-item-text-wrapper { + display: flex; + flex-direction: column; + gap: 0.25rem; + overflow: hidden; + flex: 1; + min-width: 0; } -/* Checkmark spacing in sub-menu popover: 2rem gap between text and checkmark */ -.ui5-menu-item-checked { - padding-inline-start: 2rem; +.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/main/src/MenuItemTemplate.tsx b/packages/main/src/MenuItemTemplate.tsx index 5424d503978d..beab5c83fcbe 100644 --- a/packages/main/src/MenuItemTemplate.tsx +++ b/packages/main/src/MenuItemTemplate.tsx @@ -24,7 +24,7 @@ export default function MenuItemTemplate(this: MenuItem, hooks?: Partial {currentHooks.menuItemTextContent!.call(this)} From eb7cf4b99d955178b31ed9ca8a0dc394ec0e4f34 Mon Sep 17 00:00:00 2001 From: Svilen Darvenyashki Date: Tue, 28 Apr 2026 13:15:31 +0300 Subject: [PATCH 7/7] fix(ui5-user-menu-item): scope uncheck prevention to showSelection only Single-select unchecking is now only blocked when the parent item has showSelection set, so regular single-select groups remain uncheckable as expected. --- packages/fiori/cypress/specs/UserMenu.cy.tsx | 24 ++++++++++++++++++++ packages/fiori/src/UserMenuItem.ts | 10 +++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/packages/fiori/cypress/specs/UserMenu.cy.tsx b/packages/fiori/cypress/specs/UserMenu.cy.tsx index f765956ba09b..e96860d933a9 100644 --- a/packages/fiori/cypress/specs/UserMenu.cy.tsx +++ b/packages/fiori/cypress/specs/UserMenu.cy.tsx @@ -1126,6 +1126,30 @@ describe("UserMenuItem", () => { 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", () => { diff --git a/packages/fiori/src/UserMenuItem.ts b/packages/fiori/src/UserMenuItem.ts index 1daf4e206b84..856c1eef0247 100644 --- a/packages/fiori/src/UserMenuItem.ts +++ b/packages/fiori/src/UserMenuItem.ts @@ -61,11 +61,15 @@ class UserMenuItem extends MenuItem { /** * Overrides the base MenuItem behavior to prevent unchecking - * the currently checked item in single-select mode, - * ensuring there is always a selection. + * the currently checked item in single-select mode when + * the parent item uses showSelection, ensuring there is always + * a visible selection. */ _updateCheckedState() { - if (this._checkMode === MenuItemGroupCheckMode.Single && this.checked) { + const parentItem = this.parentElement?.parentElement; + const hasShowSelection = parentItem instanceof UserMenuItem && parentItem.showSelection; + + if (hasShowSelection && this._checkMode === MenuItemGroupCheckMode.Single && this.checked) { return; } super._updateCheckedState();