From 63c54969cef33706a041477343ca063eb65643c5 Mon Sep 17 00:00:00 2001 From: Svilen Darvenyashki Date: Mon, 30 Mar 2026 09:40:58 +0300 Subject: [PATCH 1/5] 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 9cd7270a5fbbe630f797dcd23d2879c6a3e70566 Mon Sep 17 00:00:00 2001 From: Svilen Darvenyashki Date: Tue, 31 Mar 2026 00:24:16 +0300 Subject: [PATCH 2/5] 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 f11286d3a26d8072f3b2655444103c25c67a7401 Mon Sep 17 00:00:00 2001 From: Svilen Darvenyashki Date: Tue, 31 Mar 2026 00:41:50 +0300 Subject: [PATCH 3/5] 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 956838c9f8b829b25618b3ddd1a5c2b6d80a483c Mon Sep 17 00:00:00 2001 From: Svilen Darvenyashki Date: Tue, 31 Mar 2026 16:17:46 +0300 Subject: [PATCH 4/5] 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 aef9d2c7e4196e32846515917b0b099d55a2bf60 Mon Sep 17 00:00:00 2001 From: Svilen Darvenyashki Date: Thu, 16 Apr 2026 13:53:14 +0300 Subject: [PATCH 5/5] 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.