diff --git a/packages/fiori/src/UserMenuItem.ts b/packages/fiori/src/UserMenuItem.ts index 655cd54087e5..aa18ecced610 100644 --- a/packages/fiori/src/UserMenuItem.ts +++ b/packages/fiori/src/UserMenuItem.ts @@ -1,5 +1,5 @@ import { customElement, slot } from "@ui5/webcomponents-base/dist/decorators.js"; -import MenuItem from "@ui5/webcomponents/dist/MenuItem.js"; +import MenuItem, { isInstanceOfMenuItem } from "@ui5/webcomponents/dist/MenuItem.js"; import UserMenuItemTemplate from "./UserMenuItemTemplate.js"; @@ -45,7 +45,7 @@ class UserMenuItem extends MenuItem { declare items: Array; get _menuItems() { - return this.items.filter(item => !item.isSeparator); + return this.items.filter(isInstanceOfMenuItem); } } diff --git a/packages/main/cypress/specs/Menu.cy.tsx b/packages/main/cypress/specs/Menu.cy.tsx index 9229c602b577..d8ecc0db7a9c 100644 --- a/packages/main/cypress/specs/Menu.cy.tsx +++ b/packages/main/cypress/specs/Menu.cy.tsx @@ -1,6 +1,7 @@ import Button from "../../src/Button.js"; import Menu from "../../src/Menu.js"; import MenuItem from "../../src/MenuItem.js"; +import MenuItemGroup from "../../src/MenuItemGroup.js"; import openFolder from "@ui5/webcomponents-icons/dist/open-folder.js"; import addFolder from "@ui5/webcomponents-icons/dist/add-folder.js"; @@ -466,6 +467,390 @@ describe("Menu interaction", () => { }); }); + describe("Check mark is rendered for selectable and selected items", () => { + it("Selected items have check mark rendered when it is necessary", () => { + cy.mount(<> + + + + + + + + + + + + + + + + + + + + ); + + cy.get("[ui5-menu]") + .as("menu"); + + cy.get("@menu") + .find("[text='Item 1']") + .shadow() + .find("[part='content']") + .find(".ui5-menu-item-checked") + .should("not.exist"); + + cy.get("@menu") + .find("[id='groupSingle']") + .as("groupSingle"); + + cy.get("@groupSingle") + .find("[text='Item 2']") + .shadow() + .find("[part='content']") + .find(".ui5-menu-item-checked") + .should("not.exist"); + + cy.get("@groupSingle") + .find("[text='Item 3']") + .shadow() + .find("[part='content']") + .find(".ui5-menu-item-checked") + .should("exist"); + + cy.get("@menu") + .find("[id='groupMulti']") + .as("groupMulti"); + + cy.get("@groupMulti") + .find("[text='Item 4']") + .shadow() + .find("[part='content']") + .find(".ui5-menu-item-checked") + .should("exist"); + + cy.get("@groupMulti") + .find("[text='Item 5']") + .shadow() + .find("[part='content']") + .find(".ui5-menu-item-checked") + .should("exist"); + + cy.get("@groupMulti") + .find("[text='Item 6']") + .shadow() + .find("[part='content']") + .find(".ui5-menu-item-checked") + .should("not.exist"); + + cy.get("@menu") + .find("[id='groupNone']") + .as("groupNone"); + + cy.get("@groupNone") + .find("[text='Item 7']") + .shadow() + .find("[part='content']") + .find(".ui5-menu-item-checked") + .should("not.exist"); + + cy.get("@groupNone") + .find("[text='Item 8']") + .shadow() + .find("[part='content']") + .find(".ui5-menu-item-checked") + .should("not.exist"); + }); + + it("Select item (outside of any group)", () => { + cy.mount(<> + + + + + ); + + cy.get("[ui5-menu]") + .as("menu"); + + cy.get("@menu") + .find("[text='Item 1']") + .as("item") + .ui5MenuItemClick(); + + cy.get("@menu") + .find("[text='Item 1']") + .shadow() + .find(".ui5-menu-item-checked") + .should("not.exist"); + }); + + it("Select/deselect items (checkMode=Single)", () => { + cy.mount(<> + + + + + + + + ); + + cy.get("[ui5-menu]") + .as("menu"); + + cy.get("@menu") + .ui5MenuOpen({ opener: "btnOpen" }); + + cy.get("@menu") + .find("[id='groupSingle']") + .as("groupSingle"); + + cy.get("@groupSingle") + .find("[text='Item 2']") + .shadow() + .find("[part='content']") + .find(".ui5-menu-item-checked") + .should("exist"); + + cy.get("@groupSingle") + .find("[text='Item 2']") + .ui5MenuItemClick(); + + cy.get("@menu") + .ui5MenuOpen({ opener: "btnOpen" }); + + cy.get("@groupSingle") + .find("[text='Item 2']") + .shadow() + .find("[part='content']") + .find(".ui5-menu-item-checked") + .should("not.exist"); + + cy.get("@groupSingle") + .find("[text='Item 3']") + .ui5MenuItemClick(); + + cy.get("@menu") + .ui5MenuOpen({ opener: "btnOpen" }); + + cy.get("@groupSingle") + .find("[text='Item 2']") + .shadow() + .find("[part='content']") + .find(".ui5-menu-item-checked") + .should("not.exist"); + + cy.get("@groupSingle") + .find("[text='Item 3']") + .shadow() + .find("[part='content']") + .find(".ui5-menu-item-checked") + .should("exist"); + + cy.get("@groupSingle") + .find("[text='Item 3']") + .ui5MenuItemClick(); + + cy.get("@menu") + .ui5MenuOpen({ opener: "btnOpen" }); + + cy.get("@groupSingle") + .find("[text='Item 3']") + .shadow() + .find("[part='content']") + .find(".ui5-menu-item-checked") + .should("not.exist"); + }); + + it("Select/deselect items (checkMode=Multiple) ", () => { + cy.mount(<> + + + + + + + + ); + + cy.get("[ui5-menu]") + .as("menu"); + + cy.get("@menu") + .ui5MenuOpen({ opener: "btnOpen" }); + + cy.get("@menu") + .find("[id='groupMulti']") + .as("groupMulti"); + + cy.get("@groupMulti") + .find("[text='Item 4']") + .ui5MenuItemClick(); + + cy.get("@menu") + .ui5MenuOpen({ opener: "btnOpen" }); + + cy.get("@groupMulti") + .find("[text='Item 4']") + .shadow() + .find("[part='content']") + .find(".ui5-menu-item-checked") + .should("not.exist"); + + cy.get("@groupMulti") + .find("[text='Item 5']") + .ui5MenuItemClick(); + + cy.get("@menu") + .ui5MenuOpen({ opener: "btnOpen" }); + + cy.get("@groupMulti") + .find("[text='Item 4']") + .shadow() + .find("[part='content']") + .find(".ui5-menu-item-checked") + .should("not.exist"); + + cy.get("@groupMulti") + .find("[text='Item 5']") + .shadow() + .find("[part='content']") + .find(".ui5-menu-item-checked") + .should("not.exist"); + + cy.get("@menu") + .ui5MenuOpen({ opener: "btnOpen" }); + + cy.get("@groupMulti") + .find("[text='Item 4']") + .ui5MenuItemClick(); + + cy.get("@groupMulti") + .find("[text='Item 4']") + .shadow() + .find("[part='content']") + .find(".ui5-menu-item-checked") + .should("exist"); + + cy.get("@menu") + .ui5MenuOpen({ opener: "btnOpen" }); + + cy.get("@groupMulti") + .find("[text='Item 5']") + .ui5MenuItemClick(); + + cy.get("@groupMulti") + .find("[text='Item 5']") + .shadow() + .find("[part='content']") + .find(".ui5-menu-item-checked") + .should("exist"); + }); + + it("Select item (checkMode=None) ", () => { + cy.mount(<> + + + + + + + ); + + cy.get("[ui5-menu]") + .as("menu"); + + cy.get("@menu") + .find("[text='Item 6']") + .ui5MenuItemClick(); + + cy.get("@menu") + .find("[text='Item 6']") + .shadow() + .find(".ui5-menu-item-checked") + .should("not.exist"); + }); + + it("Accessibility attributes", () => { + it("Selected items have check mark rendered when it is necessary", () => { + cy.mount(<> + + + + + + + + + + + + + + + + + ); + + cy.get("[ui5-menu]") + .as("menu"); + + cy.get("@menu") + .find("[text='Item 1']") + .shadow() + .find("li") + .should("have.attr", "role", "menuitem"); + + cy.get("@menu") + .find("[id='groupSingle']") + .as("groupSingle"); + + cy.get("@groupSingle") + .shadow() + .find("div") + .should("have.attr", "role", "group"); + + cy.get("@groupSingle") + .find("[text='Item 2']") + .shadow() + .find("li") + .should("have.attr", "role", "menuitemradio"); + + cy.get("@menu") + .find("[id='groupMulti']") + .as("groupMulti"); + + cy.get("@groupMulti") + .shadow() + .find("div") + .should("have.attr", "role", "group"); + + cy.get("@groupMulti") + .find("[text='Item 4']") + .shadow() + .find("[part='selected']") + .should("have.attr", "role", "menuitemcheckbox"); + + cy.get("@menu") + .find("[id='groupNone']") + .as("groupNone"); + + cy.get("@groupNone") + .shadow() + .find("div") + .should("have.attr", "role", "group"); + + cy.get("@groupNone") + .find("[text='Item 6']") + .shadow() + .find("[part='selected']") + .should("have.attr", "role", "menuitem"); + }); + }); + }); + describe("Accessibility", () => { it("Menu and Menu items accessibility attributes", () => { cy.mount( diff --git a/packages/main/src/Menu.ts b/packages/main/src/Menu.ts index c7ac7a46ef01..5d15c1b1b20f 100644 --- a/packages/main/src/Menu.ts +++ b/packages/main/src/Menu.ts @@ -24,12 +24,14 @@ import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js"; import type { Timeout } from "@ui5/webcomponents-base/dist/types.js"; import { renderFinished } from "@ui5/webcomponents-base/dist/Render.js"; import DOMReferenceConverter from "@ui5/webcomponents-base/dist/converters/DOMReference.js"; +import type List from "./List.js"; import type ResponsivePopover from "./ResponsivePopover.js"; import type MenuItem from "./MenuItem.js"; // The import below should be kept, as MenuItem is part of the Menu component. import { isInstanceOfMenuItem } from "./MenuItem.js"; +import { isInstanceOfMenuItemGroup } from "./MenuItemGroup.js"; +import { isInstanceOfMenuSeparator } from "./MenuSeparator.js"; import type PopoverHorizontalAlign from "./types/PopoverHorizontalAlign.js"; -import "./MenuSeparator.js"; import type { ListItemClickEventDetail, } from "./List.js"; @@ -51,7 +53,9 @@ const MENU_OPEN_DELAY = 300; * @public */ interface IMenuItem extends UI5Element { - isSeparator: boolean; + isMenuItem?: boolean; + isSeparator?: boolean; + isGroup?: boolean; } type MenuItemClickEventDetail = { @@ -260,9 +264,50 @@ class Menu extends UI5Element { get _popover() { return this.shadowRoot!.querySelector("[ui5-responsive-popover]")!; } + get _list() { + return this.shadowRoot!.querySelector("[ui5-list]"); + } + + /** Returns menu item groups */ + get _menuItemGroups() { + return this.items.filter(isInstanceOfMenuItemGroup); + } + /** Returns menu items */ get _menuItems() { - return this.items.filter((item): item is MenuItem => !item.isSeparator); + return this.items.filter(isInstanceOfMenuItem); + } + + /** Returns all menu items (including those in groups */ + get _allMenuItems() { + const items: MenuItem[] = []; + + this.items.forEach(item => { + if (isInstanceOfMenuItemGroup(item)) { + items.push(...item._menuItems); + } else if (!isInstanceOfMenuSeparator(item)) { + items.push(item as MenuItem); + } + }); + + return items; + } + + /** Returns menu items included in the ItemNavigation */ + get _navigatableMenuItems() { + const items: MenuItem[] = []; + const slottedItems = this.getSlottedNodes("items"); + + slottedItems.forEach(item => { + if (isInstanceOfMenuItemGroup(item)) { + const groupItems = item.getSlottedNodes("items"); + items.push(...groupItems); + } else if (!isInstanceOfMenuSeparator(item)) { + items.push(item); + } + }); + + return items; } get acessibleNameText() { @@ -270,13 +315,21 @@ class Menu extends UI5Element { } onBeforeRendering() { - const siblingsWithIcon = this._menuItems.some(menuItem => !!menuItem.icon); + const siblingsWithIcon = this._allMenuItems.some(menuItem => !!menuItem.icon); + + this._setupItemNavigation(); - this._menuItems.forEach(item => { + this._allMenuItems.forEach(item => { item._siblingsWithIcon = siblingsWithIcon; }); } + _setupItemNavigation() { + if (this._list) { + this._list._itemNavigation._getItems = () => this._navigatableMenuItems; + } + } + _close() { this.open = false; } @@ -291,6 +344,7 @@ class Menu extends UI5Element { this.fireDecoratorEvent("before-open", { item, }); + item._popover.opener = item; item._popover.open = true; item.selected = true; @@ -314,7 +368,7 @@ class Menu extends UI5Element { async focus(focusOptions?: FocusOptions): Promise { await renderFinished(); - const firstMenuItem = this._menuItems[0]; + const firstMenuItem = this._allMenuItems[0]; if (firstMenuItem) { return firstMenuItem.focus(focusOptions); @@ -324,7 +378,7 @@ class Menu extends UI5Element { } _closeOtherSubMenus(item: MenuItem) { - const menuItems = this._menuItems; + const menuItems = this._allMenuItems; if (!menuItems.includes(item)) { return; } @@ -355,8 +409,9 @@ class Menu extends UI5Element { "text": item.text || "", }); - if (!prevented && this._popover) { - item.fireDecoratorEvent("close-menu"); + if (!prevented) { + item._updateCheckedState(); + this._popover && item.fireDecoratorEvent("close-menu"); } } else { this._openItemSubMenu(item); @@ -371,7 +426,7 @@ class Menu extends UI5Element { return; } - const menuItemInMenu = this._menuItems.includes(item); + const menuItemInMenu = this._allMenuItems.includes(item); const isItemNavigation = isUp(e) || isDown(e); const isItemSelection = isEnter(e) || isSpace(e); const isEndContentNavigation = isRight(e) || isLeft(e); @@ -396,7 +451,7 @@ class Menu extends UI5Element { _navigateOutOfEndContent(e: CustomEvent) { const item = e.target as MenuItem; const shouldNavigateToNextItem = e.detail.shouldNavigateToNextItem; - const menuItems = this._menuItems; + const menuItems = this._allMenuItems; const itemIndex = menuItems.indexOf(item); if (itemIndex > -1) { @@ -418,7 +473,7 @@ class Menu extends UI5Element { } _afterPopoverOpen() { - this._menuItems[0]?.focus(); + this._allMenuItems[0]?.focus(); this.fireDecoratorEvent("open"); } diff --git a/packages/main/src/MenuItem.ts b/packages/main/src/MenuItem.ts index c7e28bfb9b23..809970e0bcfb 100644 --- a/packages/main/src/MenuItem.ts +++ b/packages/main/src/MenuItem.ts @@ -22,10 +22,14 @@ import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js"; import NavigationMode from "@ui5/webcomponents-base/dist/types/NavigationMode.js"; import ItemNavigation from "@ui5/webcomponents-base/dist/delegate/ItemNavigation.js"; import ItemNavigationBehavior from "@ui5/webcomponents-base/dist/types/ItemNavigationBehavior.js"; +import MenuItemGroupCheckMode from "./types/MenuItemGroupCheckMode.js"; import type { ListItemAccessibilityAttributes } from "./ListItem.js"; +import type List from "./List.js"; import ListItem from "./ListItem.js"; import type ResponsivePopover from "./ResponsivePopover.js"; import type PopoverPlacement from "./types/PopoverPlacement.js"; +import { isInstanceOfMenuSeparator } from "./MenuSeparator.js"; +import { isInstanceOfMenuItemGroup } from "./MenuItemGroup.js"; import MenuItemTemplate from "./MenuItemTemplate.js"; import { MENU_BACK_BUTTON_ARIA_LABEL, @@ -80,7 +84,7 @@ type MenuItemAccessibilityAttributes = Pick("[ui5-list]")!; + } + get _navigableItems(): Array { return [...this.endContent].filter(item => { return item.hasAttribute("ui5-button") @@ -349,15 +389,14 @@ class MenuItem extends ListItem implements IMenuItem { return MenuItem.i18nBundle.getText(MENU_POPOVER_ACCESSIBLE_NAME); } - get isSeparator(): boolean { - return false; - } - onBeforeRendering() { super.onBeforeRendering(); - const siblingsWithIcon = this._menuItems.some(menuItem => !!menuItem.icon); - this._menuItems.forEach(item => { + const siblingsWithIcon = this._allMenuItems.some(menuItem => !!menuItem.icon); + + this._setupItemNavigation(); + + this._allMenuItems.forEach(item => { item._siblingsWithIcon = siblingsWithIcon; }); } @@ -366,7 +405,8 @@ class MenuItem extends ListItem implements IMenuItem { await renderFinished(); if (this.hasSubmenu && this.isSubMenuOpen) { - return this._menuItems[0].focus(focusOptions); + const menuItems = this._allMenuItems; + return menuItems[0] && menuItems[0].focus(focusOptions); } return super.focus(focusOptions); @@ -376,32 +416,93 @@ class MenuItem extends ListItem implements IMenuItem { return true; } + get _role() { + switch (this._checkMode) { + case MenuItemGroupCheckMode.Single: + return "menuitemradio"; + case MenuItemGroupCheckMode.Multiple: + return "menuitemcheckbox"; + default: + return "menuitem"; + } + } + get _accInfo() { const accInfoSettings: { role: AriaRole; ariaHaspopup?: `${AriaHasPopup}`; ariaKeyShortcuts?: string; ariaHidden?: boolean; + ariaChecked?: boolean; } = { - role: this.accessibilityAttributes.role || "menuitem", + role: this.accessibilityAttributes.role || this._role, ariaHaspopup: this.hasSubmenu ? "menu" : undefined, ariaKeyShortcuts: this.accessibilityAttributes.ariaKeyShortcuts, ariaHidden: !!this.additionalText && !!this.accessibilityAttributes.ariaKeyShortcuts ? true : undefined, + ariaChecked: this._markChecked ? true : undefined, }; return { ...super._accInfo, ...accInfoSettings }; } get _popover() { - return this.shadowRoot!.querySelector("[ui5-responsive-popover]")!; + return this.shadowRoot && this.shadowRoot.querySelector("[ui5-responsive-popover]")!; + } + + get _markChecked() { + return !this.hasSubmenu && this.checked && this._checkMode !== MenuItemGroupCheckMode.None; } + /** Returns menu item groups */ + get _menuItemGroups() { + return this.items.filter(isInstanceOfMenuItemGroup); + } + + /** Returns menu items */ get _menuItems() { - return this.items.filter((item): item is MenuItem => !item.isSeparator); + return this.items.filter(isInstanceOfMenuItem); + } + + /** Returns all menu items (including those in groups */ + get _allMenuItems() { + const items: MenuItem[] = []; + + this.items.forEach(item => { + if (isInstanceOfMenuItemGroup(item)) { + items.push(...item._menuItems); + } else if (!isInstanceOfMenuSeparator(item)) { + items.push(item as MenuItem); + } + }); + + return items; + } + + /** Returns menu items included in the ItemNavigation */ + get _navigatableMenuItems() { + const items: MenuItem[] = []; + const slottedItems = this.getSlottedNodes("items"); + + slottedItems.forEach(item => { + if (isInstanceOfMenuItemGroup(item)) { + const groupItems = item.getSlottedNodes("items"); + items.push(...groupItems); + } else if (!isInstanceOfMenuSeparator(item)) { + items.push(item); + } + }); + + return items; + } + + _setupItemNavigation() { + if (this._list) { + this._list._itemNavigation._getItems = () => this._navigatableMenuItems; + } } _closeOtherSubMenus(item: MenuItem) { - const menuItems = this._menuItems; + const menuItems = this._allMenuItems; if (!menuItems.includes(item)) { return; } @@ -429,7 +530,7 @@ class MenuItem extends ListItem implements IMenuItem { _itemKeyDown(e: KeyboardEvent) { const item = e.target as MenuItem; - const itemInMenuItems = this._menuItems.includes(item); + const itemInMenuItems = this._allMenuItems.includes(item); const isTabNextPrevious = isTabNext(e) || isTabPrevious(e); const isItemNavigation = isUp(e) || isDown(e); const isItemSelection = isSpace(e) || isEnter(e); @@ -454,7 +555,7 @@ class MenuItem extends ListItem implements IMenuItem { _navigateOutOfEndContent(e: CustomEvent) { const item = e.target as MenuItem; const shouldNavigateToNextItem = e.detail.shouldNavigateToNextItem; - const menuItems = this._menuItems; + const menuItems = this._allMenuItems; const itemIndex = menuItems.indexOf(item); if (itemIndex > -1) { @@ -477,7 +578,7 @@ class MenuItem extends ListItem implements IMenuItem { _close() { if (this._popover) { this._popover.open = false; - this._menuItems.forEach(item => item._close()); + this._allMenuItems.forEach(item => item._close()); } this.selected = false; } @@ -491,7 +592,7 @@ class MenuItem extends ListItem implements IMenuItem { } _afterPopoverOpen() { - this.items[0]?.focus(); + this._allMenuItems[0]?.focus(); this.fireDecoratorEvent("open"); } @@ -519,6 +620,17 @@ class MenuItem extends ListItem implements IMenuItem { get isMenuItem(): boolean { return true; } + + _updateCheckedState() { + if (this._checkMode === MenuItemGroupCheckMode.None) { + return; + } + + const newState = !this.checked; + + this.checked = newState; + this.fireDecoratorEvent("item-check"); + } } MenuItem.define(); diff --git a/packages/main/src/MenuItemGroup.ts b/packages/main/src/MenuItemGroup.ts new file mode 100644 index 000000000000..55f60cdcb917 --- /dev/null +++ b/packages/main/src/MenuItemGroup.ts @@ -0,0 +1,157 @@ +import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js"; +import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js"; +import property from "@ui5/webcomponents-base/dist/decorators/property.js"; +import slot from "@ui5/webcomponents-base/dist/decorators/slot.js"; +import event from "@ui5/webcomponents-base/dist/decorators/event-strict.js"; +import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js"; +import type MenuItem from "./MenuItem.js"; +import { isInstanceOfMenuItem } from "./MenuItem.js"; +import MenuItemGroupTemplate from "./MenuItemGroupTemplate.js"; +import MenuItemGroupCheckMode from "./types/MenuItemGroupCheckMode.js"; +import type { IMenuItem } from "./Menu.js"; + +type MenuItemGroupCheckChangeEventDetail = { checkedItems: Array; } + +/** + * @class + * + * ### Overview + * + * The `ui5-menu-item-group` component represents a group of items designed for use inside a `ui5-menu`. + * Items belonging to the same group should be wrapped by a `ui5-menu-item-group`. + * Each group can have an `checkMode` property, which defines the check mode for the items within the group. + * The possible values for `checkMode` are: + * - 'None' (default) - no items can be checked + * - 'Single' - Only one item can be checked at a time + * - 'Multiple' - Multiple items can be checked simultaneously + * + * **Note:** If the `checkMode` property is set to 'Single', only one item can remain checked at any given time. + * If multiple items are marked as checked, the last checked item will take precedence. + * + * ### Usage + * + * `ui5-menu-item-group` represents a collection of `ui5-menu-item` components that can have the same check mode. + * The items are addeed to the group's `items` slot. + * + * ### ES6 Module Import + * + * `import "@ui5/webcomponents/dist/MenuItemGroup.js";` + * @constructor + * @extends UI5Element + * @implements {IMenuItem} + * @since 2.12.0 + * @public + */ +@customElement({ + tag: "ui5-menu-item-group", + renderer: jsxRenderer, + template: MenuItemGroupTemplate, +}) + +/** + * Fired when an item in the group is checked or unchecked. + * @public + * @since 2.12.0 + */ +@event("check-change", { + bubbles: true, +}) +class MenuItemGroup extends UI5Element implements IMenuItem { + eventDetails!: UI5Element["eventDetails"] & { + "check-change": MenuItemGroupCheckChangeEventDetail + } + + /** + * Defines the component's check mode. + * @default "None" + * @public + */ + @property() + checkMode: `${MenuItemGroupCheckMode}` = "None"; + + /** + * Defines the items of this component. + * **Note:** The slot can hold any combination of components of type `ui5-menu-item` or `ui5-menu-separator` or both. + * @public + */ + @slot({ "default": true, type: HTMLElement, invalidateOnChildChange: true }) + items!: Array; + + get isGroup(): boolean { + return true; + } + + get _menuItems() { + return this.items.filter(isInstanceOfMenuItem); + } + + onBeforeRendering() { + this._updateItemsCheckMode(); + + if (this.checkMode === MenuItemGroupCheckMode.Single) { + this._ensureSingleItemIsChecked(); + } + } + + /** + * Sets _checkMode property of all menu items in the group. + * @private + */ + _updateItemsCheckMode() { + this._menuItems.forEach((item: MenuItem) => { + item._checkMode = this.checkMode; + }); + } + + /** + * Sets checked property of all items in the group to false. + * @private + */ + _clearCheckedItems() { + this._menuItems.forEach((item: MenuItem) => { item.checked = false; }); + } + + /** + * Ensures that only one item can remain checked at any given time. If multiple items are marked as checked, + * the last checked item will take precedence. + * @private + */ + _ensureSingleItemIsChecked() { + const lastCheckedItem = this._menuItems.findLast((item: MenuItem) => item.checked); + + this._clearCheckedItems(); + if (lastCheckedItem) { + lastCheckedItem.checked = true; + } + } + + /** + * Handles the checking of an item in the group and unchecks other items if the item check mode is Single. + * @private + */ + _handleItemCheck(e: CustomEvent) { + const clickedItem = e.target as MenuItem; + const isChecked = clickedItem.checked; + + if (this.checkMode === MenuItemGroupCheckMode.Single) { + this._clearCheckedItems(); + clickedItem.checked = isChecked; + } + + this.fireDecoratorEvent("check-change", { + checkedItems: this._menuItems.filter((item: MenuItem) => item.checked), + }); + } +} + +const isInstanceOfMenuItemGroup = (object: any): object is MenuItemGroup => { + return "isGroup" in object; +}; + +MenuItemGroup.define(); + +export default MenuItemGroup; + +export { + isInstanceOfMenuItemGroup, +}; diff --git a/packages/main/src/MenuItemGroupTemplate.tsx b/packages/main/src/MenuItemGroupTemplate.tsx new file mode 100644 index 000000000000..ddc0c853bd2b --- /dev/null +++ b/packages/main/src/MenuItemGroupTemplate.tsx @@ -0,0 +1,12 @@ +import type MenuItemGroup from "./MenuItemGroup.js"; + +export default function MenuItemGroupTemplate(this: MenuItemGroup) { + return ( +
+ +
+ ); +} diff --git a/packages/main/src/MenuItemTemplate.tsx b/packages/main/src/MenuItemTemplate.tsx index de275dfd7bbb..7815aeb00322 100644 --- a/packages/main/src/MenuItemTemplate.tsx +++ b/packages/main/src/MenuItemTemplate.tsx @@ -5,6 +5,7 @@ import List from "./List.js"; import BusyIndicator from "./BusyIndicator.js"; import navBackIcon from "@ui5/webcomponents-icons/dist/nav-back.js"; import declineIcon from "@ui5/webcomponents-icons/dist/decline.js"; +import checkIcon from "@ui5/webcomponents-icons/dist/accept.js"; import slimArrowRight from "@ui5/webcomponents-icons/dist/slim-arrow-right.js"; import Icon from "./Icon.js"; import ListItemTemplate from "./ListItemTemplate.js"; @@ -30,9 +31,21 @@ function listItemContent(this: MenuItem) { {this.text &&
{this.text}
} {rightContent.call(this)} + {checkmarkContent.call(this)} ); } +function checkmarkContent(this: MenuItem) { + return !this._markChecked ? "" : ( +
+ +
+ ); +} + function rightContent(this: MenuItem) { switch (true) { case this.hasSubmenu: diff --git a/packages/main/src/MenuSeparator.ts b/packages/main/src/MenuSeparator.ts index 8cfce33c26c5..43b5a74536d3 100644 --- a/packages/main/src/MenuSeparator.ts +++ b/packages/main/src/MenuSeparator.ts @@ -50,6 +50,15 @@ class MenuSeparator extends ListItemBase implements IMenuItem { return false; } } + +const isInstanceOfMenuSeparator = (object: any): object is MenuSeparator => { + return "isSeparator" in object; +}; + MenuSeparator.define(); export default MenuSeparator; + +export { + isInstanceOfMenuSeparator, +}; diff --git a/packages/main/src/bundle.esm.ts b/packages/main/src/bundle.esm.ts index 015e9e185a0c..6ff524ca75e5 100644 --- a/packages/main/src/bundle.esm.ts +++ b/packages/main/src/bundle.esm.ts @@ -81,6 +81,7 @@ import Link from "./Link.js"; import Menu from "./Menu.js"; import MenuItem from "./MenuItem.js"; import MenuSeparator from "./MenuSeparator.js"; +import MenuItemGroup from "./MenuItemGroup.js"; import Popover from "./Popover.js"; import Panel from "./Panel.js"; import RadioButton from "./RadioButton.js"; diff --git a/packages/main/src/themes/MenuItem.css b/packages/main/src/themes/MenuItem.css index 63c7e701de27..f9eede0aef2d 100644 --- a/packages/main/src/themes/MenuItem.css +++ b/packages/main/src/themes/MenuItem.css @@ -117,6 +117,18 @@ min-height: var(--_ui5_list_item_icon_size); display: inline-block; vertical-align: middle; - padding-inline-end: 0.5rem; + padding-inline-end: 0.75rem; pointer-events: none; } + +.ui5-menu-item-checked { + padding-inline-start: 0.5rem; + padding-inline-end: 0; + font-weight: normal; + text-align: center; +} + +.ui5-menu-item-icon-checked { + color: var(--sapContent_BusyColor); + padding-top: 0.25rem; +} \ No newline at end of file diff --git a/packages/main/src/types/MenuItemGroupCheckMode.ts b/packages/main/src/types/MenuItemGroupCheckMode.ts new file mode 100644 index 000000000000..5212bf53e49e --- /dev/null +++ b/packages/main/src/types/MenuItemGroupCheckMode.ts @@ -0,0 +1,26 @@ +/** + * Menu item group check modes. + * @since 2.12.0 + * @public + */ +enum MenuItemGroupCheckMode { + /** + * default type (items in a group cannot be checked) + * @public + */ + None = "None", + + /** + * Single item check mode (only one item in a group can be checked at a time) + * @public + */ + Single = "Single", + + /** + * Multiple items check mode (multiple items in a group can be checked at a time) + * @public + */ + Multiple = "Multiple", +} + +export default MenuItemGroupCheckMode; diff --git a/packages/main/test/pages/Menu.html b/packages/main/test/pages/Menu.html index 58c6a539569f..62e599bee87b 100644 --- a/packages/main/test/pages/Menu.html +++ b/packages/main/test/pages/Menu.html @@ -21,6 +21,7 @@ + Open Menu
@@ -83,7 +84,7 @@ Menu with items that have endContent - Open Menu + Open Menu

@@ -115,6 +116,51 @@ + Menu with item groups + Open Menu

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
@@ -123,8 +169,9 @@ - - Delayed

+ Menu with delayed items loading + Delayed +

Menu with right alignment @@ -139,13 +186,19 @@ + \ No newline at end of file diff --git a/packages/website/docs/_components_pages/main/Menu/Menu.mdx b/packages/website/docs/_components_pages/main/Menu/Menu.mdx index 6bafaf0a0018..0654df0045c8 100644 --- a/packages/website/docs/_components_pages/main/Menu/Menu.mdx +++ b/packages/website/docs/_components_pages/main/Menu/Menu.mdx @@ -7,6 +7,7 @@ import SubMenu from "../../../_samples/main/Menu/SubMenu/SubMenu.md"; import AditionalText from "../../../_samples/main/Menu/AditionalText/AditionalText.md"; import MenuEndContent from "../../../_samples/main/Menu/MenuEndContent/MenuEndContent.md"; import DynamicallyAddedItems from "../../../_samples/main/Menu/DynamicallyAddedItems/DynamicallyAddedItems.md"; +import ItemGroups from "../../../_samples/main/Menu/ItemGroups/ItemGroups.md"; <%COMPONENT_OVERVIEW%> @@ -32,4 +33,8 @@ You can nest menu items to create sub menus. ### Handling focus behaviour when adding items dynamically - \ No newline at end of file + + +### Item Groups with Checkable Menu Items + + \ No newline at end of file diff --git a/packages/website/docs/_components_pages/main/Menu/MenuItemGroup.mdx b/packages/website/docs/_components_pages/main/Menu/MenuItemGroup.mdx new file mode 100644 index 000000000000..79c54601029f --- /dev/null +++ b/packages/website/docs/_components_pages/main/Menu/MenuItemGroup.mdx @@ -0,0 +1,8 @@ +--- +slug: ../../MenuItemGroup +sidebar_class_name: newComponentBadge +--- + +<%COMPONENT_OVERVIEW%> + +<%COMPONENT_METADATA%> \ No newline at end of file diff --git a/packages/website/docs/_samples/main/Menu/ItemGroups/ItemGroups.md b/packages/website/docs/_samples/main/Menu/ItemGroups/ItemGroups.md new file mode 100644 index 000000000000..17798ecc59ab --- /dev/null +++ b/packages/website/docs/_samples/main/Menu/ItemGroups/ItemGroups.md @@ -0,0 +1,4 @@ +import html from '!!raw-loader!./sample.html'; +import js from '!!raw-loader!./main.js'; + + diff --git a/packages/website/docs/_samples/main/Menu/ItemGroups/main.js b/packages/website/docs/_samples/main/Menu/ItemGroups/main.js new file mode 100644 index 000000000000..543f38ffd20a --- /dev/null +++ b/packages/website/docs/_samples/main/Menu/ItemGroups/main.js @@ -0,0 +1,24 @@ +import "@ui5/webcomponents/dist/Menu.js"; +import "@ui5/webcomponents/dist/MenuItem.js"; +import "@ui5/webcomponents/dist/MenuSeparator.js"; +import "@ui5/webcomponents/dist/MenuItemGroup.js"; +import "@ui5/webcomponents/dist/Button.js"; + +import "@ui5/webcomponents-icons/dist/add-document.js"; +import "@ui5/webcomponents-icons/dist/slim-arrow-down.js"; +import "@ui5/webcomponents-icons/dist/text-align-left.js"; +import "@ui5/webcomponents-icons/dist/text-align-center.js"; +import "@ui5/webcomponents-icons/dist/text-align-right.js"; +import "@ui5/webcomponents-icons/dist/bold-text.js"; +import "@ui5/webcomponents-icons/dist/italic-text.js"; +import "@ui5/webcomponents-icons/dist/underline-text.js"; +import "@ui5/webcomponents-icons/dist/locked.js"; + +const btnOpenGroups = document.getElementById("btnOpenGroups"); +const menuGroups = document.getElementById("menuGroups"); + +btnOpenGroups.addEventListener("click", function(event) { + menuGroups.opener = btnOpenGroups; + menuGroups.open = !menuGroups.open; +}); + diff --git a/packages/website/docs/_samples/main/Menu/ItemGroups/sample.html b/packages/website/docs/_samples/main/Menu/ItemGroups/sample.html new file mode 100644 index 000000000000..576e5873afd4 --- /dev/null +++ b/packages/website/docs/_samples/main/Menu/ItemGroups/sample.html @@ -0,0 +1,47 @@ + + + + + + + + Sample + + + + + + Open Menu + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +