diff --git a/packages/base/src/features/OpenUI5Support.ts b/packages/base/src/features/OpenUI5Support.ts index cc4d49be4690..6e5856afa6e3 100644 --- a/packages/base/src/features/OpenUI5Support.ts +++ b/packages/base/src/features/OpenUI5Support.ts @@ -6,7 +6,7 @@ import { removeOpenedPopup, getTopmostPopup, } from "./patchPopup.js"; -import type { OpenUI5Popup, PopupInfo } from "./patchPopup.js"; +import type { OpenUI5Popup, OpenUI5PopupBasedControl, PopupInfo } from "./patchPopup.js"; import { registerFeature } from "../FeaturesRegistry.js"; import { setTheme } from "../config/Theme.js"; import type { CLDRData } from "../asset-registries/LocaleData.js"; @@ -99,7 +99,7 @@ class OpenUI5Support { OpenUI5Support.initPromise = new Promise(resolve => { window.sap.ui.require(["sap/ui/core/Core"], async (Core: OpenUI5Core) => { const callback = () => { - let deps: Array = ["sap/ui/core/Popup", "sap/ui/core/Patcher", "sap/ui/core/LocaleData"]; + let deps: Array = ["sap/ui/core/Popup", "sap/m/Dialog", "sap/m/Popover", "sap/ui/core/Patcher", "sap/ui/core/LocaleData"]; if (OpenUI5Support.isAtLeastVersion116()) { // for versions since 1.116.0 and onward, use the modular core deps = [ ...deps, @@ -110,9 +110,9 @@ class OpenUI5Support { "sap/ui/core/date/CalendarUtils", ]; } - window.sap.ui.require(deps, (Popup: OpenUI5Popup, Patcher: OpenUI5Patcher) => { + window.sap.ui.require(deps, (Popup: OpenUI5Popup, Dialog: OpenUI5PopupBasedControl, Popover: OpenUI5PopupBasedControl, Patcher: OpenUI5Patcher) => { patchPatcher(Patcher); - patchPopup(Popup); + patchPopup(Popup, Dialog, Popover); resolve(); }); }; diff --git a/packages/base/src/features/patchPopup.ts b/packages/base/src/features/patchPopup.ts index 2fed7df47f05..5bac3ab5e604 100644 --- a/packages/base/src/features/patchPopup.ts +++ b/packages/base/src/features/patchPopup.ts @@ -17,6 +17,13 @@ type OpenUI5Popup = { } }; +type OpenUI5PopupBasedControl = { + prototype: { + onsapescape: (e: Event) => void, + oPopup: OpenUI5Popup, + } +}; + type PopupInfo = { type: "OpenUI5" | "WebComponent"; instance: object; @@ -41,17 +48,16 @@ const getTopmostPopup = () => { }; /** - * Original OpenUI5 popup focus event is triggered only - * if there are no Web Component popups opened on top of it. + * Determines whether there is a Web Component popup opened above (a specified popup). * - * @param {object} popup - The popup instance to check. - * @returns {boolean} True if the focus event should be triggered, false otherwise. + * @param {object} popup The popup instance to check against. + * @returns {boolean} `true` if a Web Component popup is opened above (the given popup instance); otherwise `false`. */ -const shouldCallOpenUI5FocusEvent = (popup: object) => { +const hasWebComponentPopupAbove = (popup: object) => { for (let i = AllOpenedPopupsRegistry.openedRegistry.length - 1; i >= 0; i--) { const popupInfo = AllOpenedPopupsRegistry.openedRegistry[i]; - if (popupInfo.type !== "OpenUI5") { - return false; + if (popupInfo.type === "WebComponent") { + return true; } if (popupInfo.instance === popup) { @@ -59,7 +65,7 @@ const shouldCallOpenUI5FocusEvent = (popup: object) => { } } - return true; + return false; }; const openNativePopover = (domRef: HTMLElement) => { @@ -85,6 +91,17 @@ const isNativePopoverOpen = (root: Document | ShadowRoot = document): boolean => }); }; +const patchPopupBasedControl = (PopupBasedControl: OpenUI5PopupBasedControl) => { + const origOnsapescape = PopupBasedControl.prototype.onsapescape; + PopupBasedControl.prototype.onsapescape = function onsapescape(e: Event) { + if (hasWebComponentPopupAbove(this.oPopup)) { + return; + } + + origOnsapescape.call(this, e); + }; +}; + const patchOpen = (Popup: OpenUI5Popup) => { const origOpen = Popup.prototype.open; Popup.prototype.open = function open(...args: any[]) { @@ -125,7 +142,7 @@ const patchClosed = (Popup: OpenUI5Popup) => { const patchFocusEvent = (Popup: OpenUI5Popup) => { const origFocusEvent = Popup.prototype.onFocusEvent; Popup.prototype.onFocusEvent = function onFocusEvent(e: FocusEvent) { - if (shouldCallOpenUI5FocusEvent(this)) { + if (!hasWebComponentPopupAbove(this)) { origFocusEvent.call(this, e); } }; @@ -137,12 +154,14 @@ const createGlobalStyles = () => { document.adoptedStyleSheets = [...document.adoptedStyleSheets, stylesheet]; }; -const patchPopup = (Popup: OpenUI5Popup) => { +const patchPopup = (Popup: OpenUI5Popup, Dialog: OpenUI5PopupBasedControl, Popover: OpenUI5PopupBasedControl) => { insertOpenUI5PopupStyles(); patchOpen(Popup); // Popup.prototype.open patchClosed(Popup); // Popup.prototype._closed createGlobalStyles(); // Ensures correct popover positioning by OpenUI5 (otherwise 0,0 is the center of the screen) patchFocusEvent(Popup);// Popup.prototype.onFocusEvent + patchPopupBasedControl(Dialog); // Dialog.prototype.onsapescape + patchPopupBasedControl(Popover); // Popover.prototype.onsapescape }; export { @@ -152,4 +171,4 @@ export { getTopmostPopup, }; -export type { OpenUI5Popup, PopupInfo }; +export type { OpenUI5Popup, OpenUI5PopupBasedControl, PopupInfo }; diff --git a/packages/base/src/util/isEventMarked.ts b/packages/base/src/util/isEventMarked.ts new file mode 100644 index 000000000000..764eab1b0042 --- /dev/null +++ b/packages/base/src/util/isEventMarked.ts @@ -0,0 +1,7 @@ +const defaultOpenUI5Key = "handledByControl"; + +const isEventMarked = (event: any, key: string = defaultOpenUI5Key) => { + return !!event[`_sapui_${key}`]; +}; + +export default isEventMarked; diff --git a/packages/main/cypress/specs/OpenUI5andWebCPopups.cy.tsx b/packages/main/cypress/specs/OpenUI5andWebCPopups.cy.tsx new file mode 100644 index 000000000000..f11c71d574f2 --- /dev/null +++ b/packages/main/cypress/specs/OpenUI5andWebCPopups.cy.tsx @@ -0,0 +1,623 @@ +import OpenUI5Support from "@ui5/webcomponents-base/dist/features/OpenUI5Support.js"; +import Button from "../../src/Button.js"; +import Dialog from "../../src/Dialog.js"; +import Select from "../../src/Select.js"; +import Option from "../../src/Option.js"; +import ComboBox from "../../src/ComboBox.js"; +import ComboBoxItem from "../../src/ComboBoxItem.js"; +import ResponsivePopover from "../../src/ResponsivePopover.js"; + +function onOpenUI5InitMethod(win) { + (win as any).sap.ui.require(["sap/ui/core/HTML", "sap/m/Button", "sap/m/Dialog", "sap/m/Popover", "sap/m/Input"], async (HTML, Button, Dialog, Popover, Input) => { + + await OpenUI5Support.init(); + + new Button("openUI5Button", { + text: "Open OpenUI5 Dialog", + press: function () { + new Dialog("openUI5Dialog1", { + title: "OpenUI5 Dialog", + content: [ + new HTML({ + content: +` + Option 1 + Option 2 + Option 3 + Option 4 + Option 5 + + + + + + +

` + }), + new Input(), + new Button({ + text: "Focus stop" + }), + new Button("openResPopoverButton", { + text: "Open WebC Responsive Popover", + press: function () { + (document.getElementById("respPopover") as any).open = true; + } + }), + new Button("openResPopoverNoInitialFocusButton", { + text: "Open WebC RP with NO Initial Focus", + press: function () { + (document.getElementById("respPopoverNoInitialFocus") as any).open = true; + } + }) + ], + afterClose: function () { + this.destroy(); + } + }).open(); + } + }).placeAt("content"); + }); + + document.getElementById("myButton").addEventListener("click", function() { + (document.getElementById("dialog1") as any).open = true; + }); + + (win as any).sap.ui.require(["sap/m/Select", + "sap/m/ComboBox", + "sap/m/Button", + "sap/ui/core/Item", + "sap/ui/core/ShortcutHintsMixin"], + (Select, + ComboBox, + Button, + Item, + ShortcutHintsMixin) => { + new Select("openUI5Select1", { + items: [ + new Item({ text: "Item 1" }), + new Item({ text: "Item 2" }), + new Item({ text: "Item 3" }) + ], + change: function (oEvent) { + console.error("Selected item:", oEvent.getParameter("selectedItem").getText()); + } + }).placeAt("dialog1content"); + + new ComboBox("openUI5Combobox1", { + items: [ + new Item({ text: "Item 1" }), + new Item({ text: "Item 2" }), + new Item({ text: "Item 3" }) + ] + }).placeAt("dialog1content"); + + const button = new Button("openUI5ButtonWithHint", { + text: "OpenUI5 with Shortcut (Ctrl+S)", + press: function () { + openUI5Dialog(win); + } + }).placeAt("dialog1content"); + + + ShortcutHintsMixin.addConfig(button, { + event: "press", + position: "0 0", + addAccessibilityLabel: true, + message: "Save" + }, button); + }); + + document.getElementById("dialogButton").addEventListener("click", function () { + openUI5Dialog(win); + }); + + document.getElementById("popoverButtonNoFocus").addEventListener("click", function (event) { + openUI5Popover(win, event.target); + }); +} + +function openUI5Dialog(win) { + (win as any).sap.ui.require(["sap/m/Button", "sap/m/Dialog"], (Button, Dialog) => { + new Dialog("openUI5DialogWithButtons", { + title: "OpenUI5 Dialog", + content: [ + new Button({ + text: "Focus stop" + }), + new Button("openUI5DialogButton", { + text: "Open WebC Dialog", + press: function () { + (document.getElementById("newDialog1") as any).open = true; + } + }) + ], + afterClose: function () { + this.destroy(); + } + }).open(); + }); +} + +function openUI5Popover(win, opener) { + (win as any).sap.ui.require(["sap/m/Popover", "sap/m/Button"], (Popover, Button) => { + new Popover("openUI5PopoverSecond", { + title: "OpenUI5 Popover", + content: [ + new Button("someButton", { + text: "Open new OpenUI5 Popover", + press: function (oEvent) { + new Popover({ + title: "New OpenUI5 Popover", + placement: "Bottom", + content: [ + new Button({ + text: "Focus stop" + }) + ], + initialFocus: "someButton", + afterClose: function () { + this.destroy(); + } + }).openBy(oEvent.getSource()); + } + }) + ], + initialFocus: "popoverButtonNoFocus", + afterClose: function () { + this.destroy(); + } + }).openBy(opener); + }); +} + +describe("ui5 and web components integration", () => { + beforeEach(() => { + // mount the components + cy.mount( + <> +
+ +
+ +
+
+ Web Components: +
+ + + + + + +
+
+ + +
+ + + +
+ + + + + + + + ); + + // define initialization function before loading ui5 + cy.window().then((win) => { + (win as any).onOpenUI5Init = function () { + onOpenUI5InitMethod(win); + }; + }); + + // add ui5 bootstrap + cy.document().then((doc) => { + const ui5Script = doc.createElement('script'); + ui5Script.src = 'https://ui5.sap.com/resources/sap-ui-core.js'; + ui5Script.id = 'sap-ui-bootstrap'; + ui5Script.setAttribute('data-sap-ui-libs', 'sap.m'); + ui5Script.setAttribute('data-sap-ui-oninit', 'onOpenUI5Init'); + doc.head.appendChild(ui5Script); + }); + }); + + function OpenWebCDialog() { + cy.get("#openUI5Button", { timeout: 10000 }) + .should('be.visible'); + + cy.get('#myButton') + .should('be.visible') + .realClick(); + + cy.get("#dialog1").ui5DialogOpened(); + + cy.realPress("Escape"); + + cy.get('#dialog1') + .should('not.be.visible'); + + cy.get('#myButton') + .should('be.focused'); + } + + function OpenWebCDialogOpenUI5Select() { + cy.get("#openUI5Button") + .should('be.visible'); + + cy.get('#myButton') + .should('be.visible') + .realClick(); + + cy.get("#dialog1").ui5DialogOpened(); + + cy.get('#openUI5Select1') + .should('be.visible') + .realClick(); + + cy.get("#__popover0") + .should('be.visible'); + + cy.realPress("Escape"); + + cy.get("#__popover0") + .should('not.be.visible'); + + cy.realPress("Escape"); + + cy.get('#dialog1') + .should('not.be.visible'); + + cy.get('#myButton') + .should('be.focused'); + } + + function OpenWebCDialogOpenUI5ComboBox() { + cy.get("#openUI5Button") + .should('be.visible'); + + cy.get('#myButton') + .should('be.visible') + .realClick(); + + cy.get("#dialog1").ui5DialogOpened(); + + cy.get('#openUI5Combobox1') + .should('be.visible') + .realClick() + .type("I"); + + cy.get("#openUI5Combobox1-popup") + .should('be.visible'); + + cy.realPress("Escape"); + + cy.get("#openUI5Combobox1-popup") + .should('not.be.visible'); + + cy.get("#dialog1").ui5DialogOpened(); + + cy.realPress("Escape"); + + // combo box value is reset, dialog stays open + cy.get("#dialog1").ui5DialogOpened(); + + cy.realPress("Escape"); + + cy.get('#dialog1') + .should('not.be.visible'); + + cy.get('#myButton') + .should('be.focused'); + } + + function OpenWebCDialogOpenOpenUI5Dialog() { + cy.get("#openUI5Button") + .should('be.visible'); + + cy.get('#myButton') + .should('be.visible') + .realClick(); + + cy.get("#dialog1").ui5DialogOpened(); + + cy.get('#dialogButton') + .should('be.visible') + .realClick(); + + cy.get("#openUI5DialogWithButtons") + .should('be.visible'); + + cy.realPress("Escape"); + + cy.get("#openUI5DialogWithButtons") + .should('not.be.visible'); + + cy.get('#dialogButton') + .should('be.focused'); + + cy.realPress("Escape"); + + cy.get('#dialog1') + .should('not.be.visible'); + + cy.get('#myButton') + .should('be.focused'); + } + + function OpenWebCDialogOpenOpenUI5PopoverNoFocus() { + cy.get("#openUI5Button") + .should('be.visible'); + + cy.get('#myButton') + .should('be.visible') + .realClick(); + + cy.get("#dialog1").ui5DialogOpened(); + + cy.get('#popoverButtonNoFocus') + .should('be.visible') + .realClick(); + + cy.get("#openUI5PopoverSecond") + .should('be.visible'); + + cy.realPress("Escape"); + + cy.get("#openUI5PopoverSecond") + .should('not.exist'); + + cy.realPress(["Shift", "Tab"]); + + cy.get('#dialogButton') + .should('be.focused'); + + cy.realPress("Escape"); + + cy.get('#dialog1') + .should('not.be.visible'); + + cy.get('#myButton') + .should('be.focused'); + } + + function OpenWebCDialogOpenUI5ComboBoxNewOpenUI5DialogFromButtonWithHint() { + cy.get("#openUI5Button") + .should('be.visible'); + + cy.get('#myButton') + .should('be.visible') + .realClick(); + + cy.get("#dialog1").ui5DialogOpened(); + + cy.get('#openUI5Combobox1') + .should('be.visible') + .realClick() + .type("I"); + + cy.get("#openUI5Combobox1-popup") + .should('be.visible'); + + cy.get('#openUI5ButtonWithHint') + .should('be.visible') + .realClick(); + + cy.get("#openUI5Combobox1-popup") + .should('not.be.visible'); + + cy.get("#openUI5DialogWithButtons") + .should("be.visible"); + + cy.realPress("Escape"); + + cy.get("#openUI5DialogWithButtons") + .should("not.exist"); + + cy.get("#dialog1").ui5DialogOpened(); + + cy.get('#openUI5ButtonWithHint') + .should('be.focused') + + cy.get('#openUI5Combobox1') + .find('input') + .focus(); + + cy.realPress("Escape"); + + cy.get('#dialog1') + .should('not.be.visible'); + + cy.get('#myButton') + .should('be.focused'); + } + + function OpenUI5Dialog() { + cy.get("#openUI5Button") + .should('be.visible') + .realClick(); + + cy.get("#openUI5Dialog1") + .should('be.visible'); + + cy.realPress("Escape"); + + cy.get("#openUI5Dialog1") + .should('not.exist'); + + cy.get("#openUI5Button") + .should('be.focused'); + } + + function OpenUI5DialogWebCDialog() { + cy.get("#openUI5Button") + .should('be.visible') + .realClick(); + + cy.get("#openUI5Dialog1") + .should('be.visible'); + + cy.get("#openResPopoverButton") + .should('be.visible') + .realClick(); + + cy.get("#respPopover").ui5DialogOpened(); + + cy.realPress("Escape"); + + cy.get("#respPopover") + .should('not.be.visible'); + + cy.get("#openUI5Dialog1") + .should('be.visible'); + + cy.realPress("Escape"); + + cy.get("#openUI5Dialog1") + .should('not.exist'); + + cy.get("#openUI5Button") + .should('be.focused'); + } + + function OpenUI5DialogWebCPopoverNoFocus() { + cy.get("#openUI5Button") + .should('be.visible') + .realClick(); + + cy.get("#openUI5Dialog1") + .should('be.visible'); + + cy.get("#openResPopoverNoInitialFocusButton") + .should('be.visible') + .realClick(); + + cy.get("#respPopoverNoInitialFocus").ui5DialogOpened(); + + cy.realPress("Escape"); + + cy.get("#respPopoverNoInitialFocus") + .should('not.be.visible'); + + cy.get("#openUI5Dialog1") + .should('be.visible'); + + cy.realPress("Escape"); + + cy.get("#openUI5Dialog1") + .should('not.be.visible'); + + cy.get("#openResPopoverNoInitialFocusButton") + .should('be.focused'); + } + + function OpenUI5DialogWebCSelect() { + cy.get("#openUI5Button") + .should('be.focused') + .realClick(); + + cy.get("#openUI5Dialog1") + .should('be.visible'); + + cy.get("#webCSelect1") + .should('be.visible') + .realClick(); + + cy.get("#webCSelect1") + .shadow() + .find("[ui5-responsive-popover]").ui5DialogOpened(); + + cy.realPress("Escape"); + + cy.get("#webCSelect1") + .shadow() + .find("[ui5-responsive-popover]") + .should('not.be.visible'); + + cy.get("#openUI5Dialog1") + .should('be.visible'); + + cy.realPress("Escape"); + + cy.get("#openUI5Dialog1") + .should('not.exist'); + + cy.get("#openUI5Button") + .should('be.focused'); + } + + function OpenUI5DialogWebCComboBox() { + cy.get("#openUI5Button") + .should('be.focused') + .realClick(); + + cy.get("#openUI5Dialog1") + .should('be.visible'); + + cy.get("#webCComboBox1") + .should('be.visible'); + + cy.get("#webCComboBox1") + .shadow() + .find('input') + .realClick() + .type("A"); + + cy.get("#webCComboBox1") + .shadow() + .find("[ui5-responsive-popover]").ui5DialogOpened(); + + cy.realPress("Escape"); + + cy.get("#webCComboBox1") + .shadow() + .find("[ui5-responsive-popover]") + .should('not.be.visible'); + + cy.get("#openUI5Dialog1") + .should('be.visible'); + + cy.realPress("Escape"); + + cy.get("#openUI5Dialog1") + .should('not.exist'); + + cy.get("#openUI5Button") + .should('be.focused'); + } + + it("Keyboard", () => { + OpenWebCDialog(); + OpenWebCDialogOpenOpenUI5Dialog(); + OpenWebCDialogOpenOpenUI5PopoverNoFocus(); + OpenWebCDialogOpenUI5Select(); + OpenWebCDialogOpenUI5ComboBox(); + OpenWebCDialogOpenUI5ComboBoxNewOpenUI5DialogFromButtonWithHint(); + + OpenUI5Dialog(); + OpenUI5DialogWebCDialog(); + OpenUI5DialogWebCPopoverNoFocus(); + OpenUI5DialogWebCSelect(); + // Merge it after OpenUI5 Popup shadow dom focus fix is released + // OpenUI5DialogWebCComboBox(); + }); +}); \ No newline at end of file diff --git a/packages/main/src/ComboBox.ts b/packages/main/src/ComboBox.ts index cf59b4bb00d5..efc2eb1ec86f 100644 --- a/packages/main/src/ComboBox.ts +++ b/packages/main/src/ComboBox.ts @@ -977,7 +977,12 @@ class ComboBox extends UI5Element implements IFormInputElement { if (isEscape(e)) { this.focused = true; - this.value = !this.open ? this._lastValue : this.value; + const shouldResetValueAndStopPropagation = !this.open && this.value !== this._lastValue; + if (shouldResetValueAndStopPropagation) { + this.value = this._lastValue; + // stop propagation to prevent closing the popup when using the combobox inside it + e.stopPropagation(); + } } if ((isTabNext(e) || isTabPrevious(e)) && this.open) { diff --git a/packages/main/src/popup-utils/OpenedPopupsRegistry.ts b/packages/main/src/popup-utils/OpenedPopupsRegistry.ts index f280d47015af..df330fdb59ea 100644 --- a/packages/main/src/popup-utils/OpenedPopupsRegistry.ts +++ b/packages/main/src/popup-utils/OpenedPopupsRegistry.ts @@ -1,6 +1,7 @@ import getSharedResource from "@ui5/webcomponents-base/dist/getSharedResource.js"; import { isEscape } from "@ui5/webcomponents-base/dist/Keys.js"; import { getFeature } from "@ui5/webcomponents-base/dist/FeaturesRegistry.js"; +import isEventMarked from "@ui5/webcomponents-base/dist/util/isEventMarked.js"; import type OpenUI5Support from "@ui5/webcomponents-base/dist/features/OpenUI5Support.js"; import type Popup from "../Popup.js"; import type { PopupInfo } from "@ui5/webcomponents-base/dist/features/patchPopup.js"; @@ -64,7 +65,7 @@ const _keydownListener = (event: KeyboardEvent) => { return; } - if (isEscape(event)) { + if (isEscape(event) && !isEventMarked(event)) { const topmostPopup = OpenedPopupsRegistry.openedRegistry[OpenedPopupsRegistry.openedRegistry.length - 1].instance; if (openUI5Support && topmostPopup !== openUI5Support.getTopmostPopup()) { diff --git a/packages/main/test/pages/DialogAndOpenUI5Dialog.html b/packages/main/test/pages/DialogAndOpenUI5Dialog.html index de3c6d473964..8c118a4f8dd8 100644 --- a/packages/main/test/pages/DialogAndOpenUI5Dialog.html +++ b/packages/main/test/pages/DialogAndOpenUI5Dialog.html @@ -14,20 +14,39 @@ // delete Document.prototype.adoptedStyleSheets - - +
Open WebC Dialog
+
+ Web Components: +
+ + Option 1 + Option 2 + Option 3 + Option 4 + Option 5 + + + + + + +
+
Open UI5 dialog + Open UI5 Popover No Initial Focus
Some button @@ -133,5 +220,17 @@ header-text="This is an WebC Responsive Popover"> Some button + + Some button + + + + + + +