diff --git a/packages/fiori/cypress/specs/NotificationList.cy.tsx b/packages/fiori/cypress/specs/NotificationList.cy.tsx index d7abc4914ed9..0d625c035928 100644 --- a/packages/fiori/cypress/specs/NotificationList.cy.tsx +++ b/packages/fiori/cypress/specs/NotificationList.cy.tsx @@ -1131,3 +1131,82 @@ describe("Notification List Item Without a Group", () => { }); }); + +describe("NotificationListItem semantic click event", () => { + it("fires click event when clicked", () => { + cy.mount( + + Item 1 + + ); + + cy.get("#nli1").then(($item) => { + $item[0].addEventListener("click", cy.stub().as("clickStub")); + }); + + cy.get("#nli1").realClick(); + + cy.get("@clickStub").should("have.been.calledOnce"); + cy.get("@clickStub").should((stub: any) => { + const event = stub.firstCall.args[0]; + expect(event).to.be.instanceOf(CustomEvent); + expect(event.detail.item).to.exist; + expect(event.detail.originalEvent).to.be.instanceOf(MouseEvent); + }); + }); + + it("fires click event when activated with Enter key", () => { + cy.mount( + + Item 1 + + ); + + cy.get("#nli1").then(($item) => { + $item[0].addEventListener("click", cy.stub().as("clickStub")); + }); + + cy.get("#nli1").realClick(); + cy.realPress("Enter"); + + cy.get("@clickStub").should("have.been.calledTwice"); + }); + + it("fires click event when activated with Space key", () => { + cy.mount( + + Item 1 + + ); + + cy.get("#nli1").then(($item) => { + $item[0].addEventListener("click", cy.stub().as("clickStub")); + }); + + cy.get("#nli1").realClick(); + cy.realPress("Space"); + + cy.get("@clickStub").should("have.been.calledTwice"); + }); + + it("fires both click on item and item-click on NotificationList", () => { + cy.mount( + + Item 1 + + ); + + cy.get("#nli1").then(($item) => { + $item[0].addEventListener("click", cy.stub().as("itemClickStub")); + }); + + cy.get("#nl1").then(($list) => { + $list[0].addEventListener("ui5-item-click", cy.stub().as("listItemClickStub")); + }); + + cy.get("#nli1").realClick(); + + cy.get("@itemClickStub").should("have.been.calledOnce"); + cy.get("@listItemClickStub").should("have.been.calledOnce"); + }); +}); diff --git a/packages/fiori/src/NotificationListItem.ts b/packages/fiori/src/NotificationListItem.ts index a63dcf109bc6..722991a526c5 100644 --- a/packages/fiori/src/NotificationListItem.ts +++ b/packages/fiori/src/NotificationListItem.ts @@ -58,6 +58,11 @@ type NotificationListItemPressEventDetail = { item: NotificationListItem, }; +type NotificationListItemClickEventDetail = { + item: NotificationListItem, + originalEvent: Event, +}; + type Footnote = Record; /** @@ -141,6 +146,18 @@ const ICON_PER_STATUS_DESIGN = { bubbles: true, }) +/** + * Fired when the component is activated either with a mouse/tap or by using the Enter or Space key. + * + * @since 2.22.0 + * @public + * @param {NotificationListItem} item The activated item. + * @param {Event} originalEvent The original event from the user interaction. + */ +@event("click", { + bubbles: true, +}) + /** * Fired when the `Close` button is pressed. * @param {HTMLElement} item the closed item. @@ -160,6 +177,7 @@ const ICON_PER_STATUS_DESIGN = { class NotificationListItem extends NotificationListItemBase { eventDetails!: NotificationListItemBase["eventDetails"] & { _press: NotificationListItemPressEventDetail, + click: NotificationListItemClickEventDetail, close: NotificationListItemCloseEventDetail, _close: NotificationListItemCloseEventDetail, } @@ -478,8 +496,9 @@ class NotificationListItem extends NotificationListItemBase { /** * Event handlers */ - _onclick() { - this.fireItemPress(); + _onclick(e: MouseEvent) { + e.stopPropagation(); + this.fireItemPress(e); } _onShowMoreClick(e: UI5CustomEvent) { @@ -549,7 +568,7 @@ class NotificationListItem extends NotificationListItemBase { /** * Private */ - fireItemPress() { + fireItemPress(e: Event) { if (this.getFocusDomRef()!.matches(":has(:focus-within)")) { return; } @@ -557,6 +576,7 @@ class NotificationListItem extends NotificationListItemBase { // NotificationListItem will never be assigned to a variable of type ListItemBase // typescipt complains here, if that is the case, the parameter to the _press event handler could be a ListItemBase item, // but this is never the case, all components are used by their class and never assigned to a variable with a type of ListItemBase + this.fireDecoratorEvent("click", { item: this, originalEvent: e }); this.fireDecoratorEvent("_press", { item: this }); } @@ -591,5 +611,6 @@ NotificationListItem.define(); export default NotificationListItem; export type { NotificationListItemPressEventDetail, + NotificationListItemClickEventDetail, NotificationListItemCloseEventDetail, }; diff --git a/packages/fiori/src/NotificationListItemBase.ts b/packages/fiori/src/NotificationListItemBase.ts index 8519fdf3dca4..a01f5bdf52df 100644 --- a/packages/fiori/src/NotificationListItemBase.ts +++ b/packages/fiori/src/NotificationListItemBase.ts @@ -1,4 +1,5 @@ import { isSpace, isF2 } from "@ui5/webcomponents-base/dist/Keys.js"; +import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js"; import property from "@ui5/webcomponents-base/dist/decorators/property.js"; import i18n from "@ui5/webcomponents-base/dist/decorators/i18n.js"; import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js"; @@ -21,6 +22,7 @@ import { * @since 1.0.0-rc.8 * @public */ +@customElement({}) class NotificationListItemBase extends ListItemBase { eventDetails!: ListItemBase["eventDetails"]; /** diff --git a/packages/fiori/src/Search.ts b/packages/fiori/src/Search.ts index c9e0e6ae4637..c29e43beb832 100755 --- a/packages/fiori/src/Search.ts +++ b/packages/fiori/src/Search.ts @@ -39,11 +39,13 @@ import type Input from "@ui5/webcomponents/dist/Input.js"; import type { PopupBeforeCloseEventDetail } from "@ui5/webcomponents/dist/Popup.js"; import type Select from "@ui5/webcomponents/dist/Select.js"; import type { Slot, DefaultSlot } from "@ui5/webcomponents-base/dist/UI5Element.js"; +import type { ListItemBaseClickEventDetail } from "@ui5/webcomponents/dist/ListItemBase.js"; interface ISearchSuggestionItem extends UI5Element { selected: boolean; text: string; items?: ISearchSuggestionItem[]; + eventDetails: { click?: ListItemBaseClickEventDetail }; } type SearchEventDetails = { diff --git a/packages/fiori/src/SearchItemShowMore.ts b/packages/fiori/src/SearchItemShowMore.ts index 12be2a4591a2..8bc02f70ab55 100644 --- a/packages/fiori/src/SearchItemShowMore.ts +++ b/packages/fiori/src/SearchItemShowMore.ts @@ -2,6 +2,7 @@ import customElement from "@ui5/webcomponents-base/dist/decorators/customElement import property from "@ui5/webcomponents-base/dist/decorators/property.js"; import event from "@ui5/webcomponents-base/dist/decorators/event-strict.js"; import ListItemBase from "@ui5/webcomponents/dist/ListItemBase.js"; +import type { ListItemBaseClickEventDetail } from "@ui5/webcomponents/dist/ListItemBase.js"; import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js"; import i18n from "@ui5/webcomponents-base/dist/decorators/i18n.js"; import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js"; @@ -11,7 +12,7 @@ import SearchItemShowMoreCss from "./generated/themes/SearchItemShowMore.css.js" import { SEARCH_ITEM_SHOW_MORE_COUNT, SEARCH_ITEM_SHOW_MORE_NO_COUNT } from "./generated/i18n/i18n-defaults.js"; import { isEnter, isSpace } from "@ui5/webcomponents-base/dist/Keys.js"; -type ShowMoreItemClickEventDetail = { +type ShowMoreItemClickEventDetail = ListItemBaseClickEventDetail & { fromKeyboard: boolean; } @@ -100,7 +101,7 @@ If a number is provided, it displays "Show more (N)", where N is that number. _onclick(e: MouseEvent | KeyboardEvent, fromKeyboard = false) { e.stopImmediatePropagation(); - this.fireDecoratorEvent("click", { fromKeyboard }); + this.fireDecoratorEvent("click", { item: this, originalEvent: e, fromKeyboard }); } _onkeydown(e: KeyboardEvent) { diff --git a/packages/main/cypress/specs/TabContainer.cy.tsx b/packages/main/cypress/specs/TabContainer.cy.tsx index 90297c29a222..9020c529d239 100644 --- a/packages/main/cypress/specs/TabContainer.cy.tsx +++ b/packages/main/cypress/specs/TabContainer.cy.tsx @@ -1287,3 +1287,103 @@ describe("TabContainer popover", () => { cy.get("@list").find(".ui5-tab-overflow-itemContent-wrapper").eq(3).should("have.css", "padding-left", "24px"); }); }); + +describe("Tab semantic click event", () => { + it("fires click event on tab when clicked", () => { + cy.mount( + + + + + ); + + cy.get("#tab1").then(($tab) => { + $tab[0].addEventListener("click", cy.stub().as("clickStub")); + }); + + cy.get("#tc1").shadow().find(".ui5-tab-strip-item:nth-child(1)").realClick(); + + cy.get("@clickStub").should("have.been.calledOnce"); + cy.get("@clickStub").should((stub: any) => { + const event = stub.firstCall.args[0]; + expect(event).to.be.instanceOf(CustomEvent); + expect(event.detail.tab).to.exist; + expect(event.detail.originalEvent).to.be.instanceOf(MouseEvent); + }); + }); + + it("fires click event on tab when activated with Enter key", () => { + cy.mount( + + + + + ); + + cy.get("#tab1").then(($tab) => { + $tab[0].addEventListener("click", cy.stub().as("clickStub")); + }); + + cy.get("#tc1").shadow().find(".ui5-tab-strip-item:nth-child(1)").realClick(); + cy.realPress("Enter"); + + cy.get("@clickStub").should("have.been.calledTwice"); + }); + + it("fires click event on tab when activated with Space key", () => { + cy.mount( + + + + + ); + + cy.get("#tab1").then(($tab) => { + $tab[0].addEventListener("click", cy.stub().as("clickStub")); + }); + + cy.get("#tc1").shadow().find(".ui5-tab-strip-item:nth-child(1)").realClick(); + cy.realPress("Space"); + + cy.get("@clickStub").should("have.been.calledTwice"); + }); + + it("does not fire click event on disabled tab", () => { + cy.mount( + + + + + ); + + cy.get("#tab1").then(($tab) => { + $tab[0].addEventListener("click", cy.stub().as("clickStub")); + }); + + cy.get("#tc1").shadow().find(".ui5-tab-strip-item:nth-child(1)").click({ force: true }); + + cy.get("@clickStub").should("not.have.been.called"); + }); + + it("fires both click on Tab and tab-select on TabContainer", () => { + cy.mount( + + + + + ); + + cy.get("#tab1").then(($tab) => { + $tab[0].addEventListener("click", cy.stub().as("tabClickStub")); + }); + + cy.get("#tc1").then(($tc) => { + $tc[0].addEventListener("ui5-tab-select", cy.stub().as("tabSelectStub")); + }); + + cy.get("#tc1").shadow().find(".ui5-tab-strip-item:nth-child(1)").realClick(); + + cy.get("@tabClickStub").should("have.been.calledOnce"); + cy.get("@tabSelectStub").should("have.been.calledOnce"); + }); +}); diff --git a/packages/main/src/ComboBox.ts b/packages/main/src/ComboBox.ts index 582cb67e97c8..b1289e42c801 100644 --- a/packages/main/src/ComboBox.ts +++ b/packages/main/src/ComboBox.ts @@ -90,6 +90,7 @@ import { isInstanceOfComboBoxItemGroup } from "./ComboBoxItemGroup.js"; import type ComboBoxFilter from "./types/ComboBoxFilter.js"; import type Input from "./Input.js"; import type { InputEventDetail } from "./Input.js"; +import type { ListItemBaseClickEventDetail } from "./ListItemBase.js"; import type InputComposition from "./features/InputComposition.js"; const SKIP_ITEMS_SIZE = 10; @@ -107,7 +108,8 @@ interface IComboBoxItem extends UI5Element { selected?: boolean, additionalText?: string, _isVisible?: boolean, - items?: Array + items?: Array, + eventDetails: { click?: ListItemBaseClickEventDetail }, } type ValueStateAnnouncement = Record, string>; diff --git a/packages/main/src/Input.ts b/packages/main/src/Input.ts index a17b552cb953..2da282911963 100644 --- a/packages/main/src/Input.ts +++ b/packages/main/src/Input.ts @@ -97,6 +97,7 @@ import ResponsivePopoverCommonCss from "./generated/themes/ResponsivePopoverComm import ValueStateMessageCss from "./generated/themes/ValueStateMessage.css.js"; import SuggestionsCss from "./generated/themes/Suggestions.css.js"; import type { ListItemClickEventDetail, ListSelectionChangeEventDetail } from "./List.js"; +import type { ListItemBaseClickEventDetail } from "./ListItemBase.js"; import type ResponsivePopover from "./ResponsivePopover.js"; import type InputKeyHint from "./types/InputKeyHint.js"; import type InputComposition from "./features/InputComposition.js"; @@ -110,6 +111,7 @@ interface IInputSuggestionItem extends UI5Element { focused: boolean; additionalText?: string; items?: IInputSuggestionItem[]; + eventDetails: { click?: ListItemBaseClickEventDetail }; } interface IInputSuggestionItemSelectable extends IInputSuggestionItem { diff --git a/packages/main/src/ListItemBase.ts b/packages/main/src/ListItemBase.ts index 5ea470bdf9e3..a018de6146d2 100644 --- a/packages/main/src/ListItemBase.ts +++ b/packages/main/src/ListItemBase.ts @@ -25,6 +25,11 @@ type ListItemBasePressEventDetail = { key?: string, } +type ListItemBaseClickEventDetail = { + item: ListItemBase, + originalEvent: Event, +} + /** * @class * A class to serve as a foundation @@ -38,6 +43,19 @@ type ListItemBasePressEventDetail = { renderer: jsxRenderer, styles: [styles, draggableElementStyles], }) +/** + * Fired when the component is activated either with a mouse/tap or by using the Enter or Space key. + * + * **Note:** The event will not be fired if the `disabled` property is set to `true`. + * + * @since 2.22.0 + * @public + * @param {ListItemBase} item The activated item. + * @param {Event} originalEvent The original event from the user interaction. + */ +@event("click", { + bubbles: true, +}) @event("request-tabindex-change", { bubbles: true, }) @@ -56,6 +74,7 @@ type ListItemBasePressEventDetail = { }) class ListItemBase extends UI5Element implements ITabbable { eventDetails!: { + "click": ListItemBaseClickEventDetail, "request-tabindex-change": FocusEvent, "_press": ListItemBasePressEventDetail, "_focused": FocusEvent, @@ -168,6 +187,7 @@ class ListItemBase extends UI5Element implements ITabbable { if (this.getFocusDomRef()!.matches(":has(:focus-within)") || this._isDisabledInteractiveContentClicked(e)) { return; } + e.stopPropagation(); this.fireItemPress(e); } @@ -233,6 +253,7 @@ class ListItemBase extends UI5Element implements ITabbable { if (isEnter(e as KeyboardEvent)) { e.preventDefault(); } + this.fireDecoratorEvent("click", { item: this, originalEvent: e }); this.fireDecoratorEvent("_press", { item: this, selected: this.selected, key: (e as KeyboardEvent).key }); } @@ -313,4 +334,5 @@ export default ListItemBase; export type { ListItemBasePressEventDetail, + ListItemBaseClickEventDetail, }; diff --git a/packages/main/src/ListItemGroup.ts b/packages/main/src/ListItemGroup.ts index b5179a3fd74f..0a52d9f49db4 100644 --- a/packages/main/src/ListItemGroup.ts +++ b/packages/main/src/ListItemGroup.ts @@ -9,6 +9,7 @@ import DragAndDropHandler from "./delegate/DragAndDropHandler.js"; import MovePlacement from "@ui5/webcomponents-base/dist/types/MovePlacement.js"; import type DropIndicator from "./DropIndicator.js"; import type ListItemBase from "./ListItemBase.js"; +import type { ListItemBaseClickEventDetail } from "./ListItemBase.js"; // Template import ListItemGroupTemplate from "./ListItemGroupTemplate.js"; @@ -83,6 +84,7 @@ type ListItemGroupMoveEventDetail = { class ListItemGroup extends UI5Element { eventDetails!: { + "click"?: ListItemBaseClickEventDetail, "move-over": ListItemGroupMoveEventDetail, "move": ListItemGroupMoveEventDetail, } diff --git a/packages/main/src/Menu.ts b/packages/main/src/Menu.ts index 0c9da93d0a80..32105ba1fc5e 100644 --- a/packages/main/src/Menu.ts +++ b/packages/main/src/Menu.ts @@ -1,5 +1,6 @@ import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js"; import type { DefaultSlot } from "@ui5/webcomponents-base/dist/UI5Element.js"; +import type { ListItemBaseClickEventDetail } from "./ListItemBase.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-strict.js"; @@ -60,6 +61,7 @@ interface IMenuItem extends UI5Element { isMenuItem?: boolean; isSeparator?: boolean; isGroup?: boolean; + eventDetails: { click?: ListItemBaseClickEventDetail }; } type MenuItemClickEventDetail = { diff --git a/packages/main/src/MultiComboBox.ts b/packages/main/src/MultiComboBox.ts index 9fc722e58fda..245aaf53cf00 100644 --- a/packages/main/src/MultiComboBox.ts +++ b/packages/main/src/MultiComboBox.ts @@ -112,6 +112,7 @@ import type ComboBoxFilter from "./types/ComboBoxFilter.js"; import CheckBox from "./CheckBox.js"; import Input from "./Input.js"; import type { InputEventDetail } from "./Input.js"; +import type { ListItemBaseClickEventDetail } from "./ListItemBase.js"; import SuggestionItem from "./SuggestionItem.js"; import type InputComposition from "./features/InputComposition.js"; @@ -128,6 +129,7 @@ interface IMultiComboBoxItem extends UI5Element { isGroupItem?: boolean, _isVisible?: boolean, items?: Array, + eventDetails: { click?: ListItemBaseClickEventDetail }, } type ValueStateAnnouncement = Record, string>; diff --git a/packages/main/src/Tab.ts b/packages/main/src/Tab.ts index d954939fca3d..d44ae0a22151 100644 --- a/packages/main/src/Tab.ts +++ b/packages/main/src/Tab.ts @@ -1,6 +1,7 @@ import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js"; import type { Slot, DefaultSlot } from "@ui5/webcomponents-base/dist/UI5Element.js"; import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js"; +import event from "@ui5/webcomponents-base/dist/decorators/event-strict.js"; import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js"; import property from "@ui5/webcomponents-base/dist/decorators/property.js"; import slot from "@ui5/webcomponents-base/dist/decorators/slot-strict.js"; @@ -48,6 +49,11 @@ const DESIGN_DESCRIPTIONS = { [SemanticColor.Critical]: TAB_ARIA_DESIGN_CRITICAL, }; +type TabClickEventDetail = { + tab: Tab, + originalEvent: Event, +}; + interface TabInStrip extends HTMLElement { realTabReference: Tab; } @@ -74,7 +80,21 @@ interface TabInOverflow extends ListItemCustom { template: TabTemplate, styles: css, }) +/** + * Fired when the tab is selected either with a mouse/tap or by using the Enter or Space key. + * + * @since 2.22.0 + * @public + * @param {Tab} tab The selected tab. + * @param {Event} originalEvent The original event from the user interaction. + */ +@event("click", { + bubbles: true, +}) class Tab extends UI5Element implements ITabbable, ITab { + eventDetails!: { + click: TabClickEventDetail, + } /** * The text to be displayed for the item. * @default undefined @@ -527,4 +547,5 @@ export default Tab; export type { TabInStrip, TabInOverflow, + TabClickEventDetail, }; diff --git a/packages/main/src/TabContainer.ts b/packages/main/src/TabContainer.ts index 3e5cdfb09850..9e4e981d3d81 100644 --- a/packages/main/src/TabContainer.ts +++ b/packages/main/src/TabContainer.ts @@ -47,7 +47,7 @@ import type Button from "./Button.js"; import type List from "./List.js"; import type DropIndicator from "./DropIndicator.js"; import type Tab from "./Tab.js"; -import type { TabInStrip, TabInOverflow } from "./Tab.js"; +import type { TabInStrip, TabInOverflow, TabClickEventDetail } from "./Tab.js"; import type { TabSeparatorInStrip } from "./TabSeparator.js"; import type { ListItemClickEventDetail, ListMoveEventDetail } from "./List.js"; import type ResponsivePopover from "./ResponsivePopover.js"; @@ -111,6 +111,7 @@ interface ITab extends UI5Element { receiveOverflowInfo: (arg0: TabContainerOverflowInfo) => void; getDomRefInStrip: () => HTMLElement | undefined; items?: Array; + eventDetails: { click?: TabClickEventDetail }; } /** @@ -745,7 +746,7 @@ class TabContainer extends UI5Element { return; } - this._onHeaderItemSelect(tab); + this._onHeaderItemSelect(tab, e); } async _onTabExpandButtonClick(e: Event) { @@ -772,7 +773,7 @@ class TabContainer extends UI5Element { // if clicked between the expand button and the tab if (!tabInstance) { - this._onHeaderItemSelect(opener.parentElement as HTMLElement); + this._onHeaderItemSelect(opener.parentElement as HTMLElement, e); return; } @@ -827,7 +828,7 @@ class TabContainer extends UI5Element { if (tab.realTabReference.isSingleClickArea) { this._onTabStripClick(e); } else { - this._onHeaderItemSelect(tab); + this._onHeaderItemSelect(tab, e); } } @@ -856,21 +857,21 @@ class TabContainer extends UI5Element { if (tab.realTabReference.isSingleClickArea) { this._onTabStripClick(e); } else { - this._onHeaderItemSelect(tab); + this._onHeaderItemSelect(tab, e); } } } - _onHeaderItemSelect(tab: HTMLElement) { + _onHeaderItemSelect(tab: HTMLElement, originalEvent?: Event) { if (!tab.hasAttribute("disabled")) { - this._onItemSelect(tab.id); + this._onItemSelect(tab.id, originalEvent); } } async _onOverflowListItemClick(e: CustomEvent) { e.preventDefault(); // cancel the item selection - this._onItemSelect(e.detail.item.id.slice(0, -3)); // strip "-li" from end of id + this._onItemSelect(e.detail.item.id.slice(0, -3), e); // strip "-li" from end of id this._closePopover(); await renderFinished(); @@ -901,7 +902,7 @@ class TabContainer extends UI5Element { return result; } - _onItemSelect(selectedTabId: string) { + _onItemSelect(selectedTabId: string, originalEvent?: Event) { const selectedTabIndex = this._itemsFlat.findIndex(item => item.__id === selectedTabId); const selectedTab = this._itemsFlat[selectedTabIndex] as Tab; @@ -910,6 +911,10 @@ class TabContainer extends UI5Element { return; } + if (originalEvent) { + selectedTab.fireDecoratorEvent("click", { tab: selectedTab, originalEvent }); + } + // update selected property on all items this._itemsFlat.forEach(item => { if (!item.isSeparator) {