diff --git a/packages/fiori/cypress/specs/Search.cy.tsx b/packages/fiori/cypress/specs/Search.cy.tsx index ee955c73321a8..ec6e35c87e10a 100644 --- a/packages/fiori/cypress/specs/Search.cy.tsx +++ b/packages/fiori/cypress/specs/Search.cy.tsx @@ -1431,7 +1431,9 @@ describe("Events", () => { .should("have.been.calledOnce"); cy.get("[ui5-search]") - .should("have.attr", "open"); + .shadow() + .find("#ui5-search-list") + .ui5ResponsivePopoverOpened(); }); it("open event - typing, pressing Escape, then typing again should reopen suggestions", () => { @@ -1941,6 +1943,65 @@ describe("Events", () => { .ui5ResponsivePopoverOpened(); }); + it("should open picker when 'open' property is set to true by the application", () => { + cy.mount( + + + + ); + + // The picker is initially closed + cy.get("[ui5-search]") + .shadow() + .find("#ui5-search-list") + .ui5ResponsivePopoverClosed(); + + // The application sets the public "open" property to true + cy.get("[ui5-search]") + .then($search => { + ($search.get(0) as Search).open = true; + }); + + // The picker opens + cy.get("[ui5-search]") + .shadow() + .find("#ui5-search-list") + .ui5ResponsivePopoverOpened(); + }); + + it("should open picker when the user starts typing in the search", () => { + cy.mount( + + + + + ); + + // The picker is initially closed + cy.get("[ui5-search]") + .shadow() + .find("#ui5-search-list") + .ui5ResponsivePopoverClosed(); + + // The user focuses the input and starts typing + cy.get("[ui5-search]") + .shadow() + .find("input") + .realClick(); + + cy.get("[ui5-search]") + .should("be.focused"); + + cy.get("[ui5-search]") + .realPress("I"); + + // The picker opens as content becomes available + cy.get("[ui5-search]") + .shadow() + .find("#ui5-search-list") + .ui5ResponsivePopoverOpened(); + }); + it("should not open picker if text is deleted and there are no items", () => { const handleInput = (e: any) => { if (e.target.value) { @@ -2147,12 +2208,13 @@ describe("Lazy loaded items and autocomplete", () => { let searchComponent: any; const handleInput = () => { - setTimeout(() => + setTimeout(() => { searchItems.forEach(data => { const item = document.createElement("ui5-search-item"); item.setAttribute("text", data.text); searchComponent.appendChild(item); - }), + }); + }, 1000) }; @@ -2195,4 +2257,185 @@ describe("Lazy loaded items and autocomplete", () => { cy.get("[ui5-search]") .should("have.value", "Apple"); }); + + it("keeps effective open state in sync across dynamic loading, focus out and item selection", () => { + const searchItems = [ + { text: "Manage Users" }, + { text: "Manage Roles" }, + { text: "Manage Settings" }, + ]; + + let searchComponent: any; + let timer: ReturnType; + + // Dynamically loads 3 items (all containing "Manage") with a short delay, + // toggling the loading state so the popover shows a busy indicator meanwhile. + const loadItems = () => { + clearTimeout(timer); + searchComponent.innerHTML = ""; + + if (!searchComponent.value) { + searchComponent.loading = false; + return; + } + + searchComponent.loading = true; + + timer = setTimeout(() => { + searchComponent.innerHTML = ""; + searchItems.forEach(data => { + const item = document.createElement("ui5-search-item"); + item.setAttribute("text", data.text); + searchComponent.appendChild(item); + }); + searchComponent.loading = false; + }, 300); + }; + + cy.mount( + <> + { searchComponent = el; }} onInput={loadItems}> + + + ); + + cy.get("[ui5-search]").as("search"); + + // Focus the input and type "Manage" + cy.get("@search") + .shadow() + .find("input") + .realClick(); + + cy.get("@search") + .should("be.focused"); + + cy.get("@search") + .realType("Manage"); + + // 3 items containing "Manage" are loaded dynamically + cy.get("[ui5-search-item]") + .should("have.length", 3); + + // The popover is open once the items are loaded + cy.get("@search") + .shadow() + .find("#ui5-search-list") + .ui5ResponsivePopoverOpened(); + + // Focus out from the search (move focus to the sibling button) + cy.get("@search") + .realPress("Tab"); + + // The popover is closed + cy.get("@search") + .shadow() + .find("#ui5-search-list") + .ui5ResponsivePopoverClosed(); + + // Click again in the search and delete the last letter -> "Manag" + cy.get("@search") + .shadow() + .find("input") + .realClick(); + + cy.get("@search") + .realPress("Backspace"); + + cy.get("@search") + .should("have.value", "Manag"); + + cy.get("@search") + .shadow() + .find("#ui5-search-list") + .ui5ResponsivePopoverOpened(); + + cy.get("ui5-search-item") + .should("have.length", 3); + + // Click on the first item that starts with "Manag" + cy.get("ui5-search-item") + .eq(0) + .realClick(); + + // The popover is not open after the click + cy.get("@search") + .shadow() + .find("#ui5-search-list") + .ui5ResponsivePopoverClosed(); + }); +}); + +describe("Open property", () => { + it("should close the picker when the application sets 'open' to false while the search is focused", () => { + cy.mount( + + + + + ); + + // The focus is placed in the search + cy.get("[ui5-search]") + .shadow() + .find("input") + .realClick(); + + cy.get("[ui5-search]") + .should("be.focused"); + + cy.get("[ui5-search]") + .shadow() + .find("input") + .realType("It"); + + // The picker is open + cy.get("[ui5-search]") + .shadow() + .find("#ui5-search-list") + .ui5ResponsivePopoverOpened(); + + // The application closes the picker from outside + cy.get("[ui5-search]") + .then($search => { + ($search.get(0) as Search).open = false; + }); + + // The picker is closed + cy.get("[ui5-search]") + .shadow() + .find("#ui5-search-list") + .ui5ResponsivePopoverClosed(); + }); + + it("should open the picker when the application sets 'open' to true while the search is not focused", () => { + cy.mount( + + + + + ); + + // The search is not focused + cy.get("[ui5-search]") + .should("not.be.focused"); + + // The picker is initially closed + cy.get("[ui5-search]") + .shadow() + .find("#ui5-search-list") + .ui5ResponsivePopoverClosed(); + + // The application opens the picker from outside + cy.get("[ui5-search]") + .then($search => { + ($search.get(0) as Search).open = true; + }); + + // The picker is open + cy.get("[ui5-search]") + .shadow() + .find("#ui5-search-list") + .ui5ResponsivePopoverOpened(); + }); }); diff --git a/packages/fiori/cypress/specs/ShellBarSearch.cy.tsx b/packages/fiori/cypress/specs/ShellBarSearch.cy.tsx index 3651178ebe555..f72f167bc3bfd 100644 --- a/packages/fiori/cypress/specs/ShellBarSearch.cy.tsx +++ b/packages/fiori/cypress/specs/ShellBarSearch.cy.tsx @@ -1,5 +1,7 @@ import SearchItem from "../../src/SearchItem.js"; import ShellBarSearch from "../../src/ShellBarSearch.js"; +import Button from "@ui5/webcomponents/dist/Button.js"; +import type ResponsivePopover from "@ui5/webcomponents/dist/ResponsivePopover.js"; import { SHELLBAR_SEARCH_COLLAPSED, SEARCH_FIELD_SEARCH_ICON, @@ -108,4 +110,184 @@ describe("Behaviour", () => { .find("[ui5-button]") .should("have.focus"); }); + + it("keeps effective open state in sync across dynamic loading, focus out and item selection", () => { + const searchItems = [ + { text: "Manage Users" }, + { text: "Manage Roles" }, + { text: "Manage Settings" }, + ]; + + let searchComponent: any; + let timer: ReturnType; + + // Dynamically loads 3 items (all containing "Manage") with a short delay, + // toggling the loading state so the popover shows a busy indicator meanwhile. + const loadItems = () => { + clearTimeout(timer); + searchComponent.innerHTML = ""; + + if (!searchComponent.value) { + searchComponent.loading = false; + return; + } + + searchComponent.loading = true; + + timer = setTimeout(() => { + searchComponent.innerHTML = ""; + searchItems.forEach(data => { + const item = document.createElement("ui5-search-item"); + item.setAttribute("text", data.text); + searchComponent.appendChild(item); + }); + searchComponent.loading = false; + }, 300); + }; + + cy.mount( + <> + { searchComponent = el; }} onInput={loadItems}> + + + ); + + cy.get("[ui5-shellbar-search]").as("search"); + + // Focus the input and type "Manage" + cy.get("@search") + .shadow() + .find("input") + .realClick(); + + cy.get("@search") + .should("be.focused"); + + cy.get("@search") + .realType("Manage"); + + // 3 items containing "Manage" are loaded dynamically + cy.get("ui5-search-item") + .should("have.length", 3); + + // The popover is open once the items are loaded + cy.get("@search") + .shadow() + .find("#ui5-search-list") + .ui5ResponsivePopoverOpened(); + + // Focus out from the search (move focus to the sibling button) + cy.get("@search") + .realPress("Tab"); + + // The popover is closed + cy.get("@search") + .shadow() + .find("#ui5-search-list") + .ui5ResponsivePopoverClosed(); + + // Click again in the search and delete the last letter -> "Manag" + cy.get("@search") + .shadow() + .find("input") + .realClick(); + + cy.get("@search") + .realPress("Backspace"); + + cy.get("@search") + .should("have.value", "Manag"); + + cy.get("@search") + .shadow() + .find("#ui5-search-list") + .ui5ResponsivePopoverOpened(); + + cy.get("ui5-search-item") + .should("have.length", 3); + + // Click on the first item that starts with "Manag" + cy.get("ui5-search-item") + .eq(0) + .realClick(); + + // The popover is not open after the click + cy.get("@search") + .shadow() + .find("#ui5-search-list") + .ui5ResponsivePopoverClosed(); + }); +}); + +describe("Open property", () => { + it("should close the picker when the application sets 'open' to false while the search is focused", () => { + cy.mount( + + + + + ); + + // The focus is placed in the search + cy.get("[ui5-shellbar-search]") + .shadow() + .find("input") + .realClick(); + + cy.get("[ui5-shellbar-search]") + .should("be.focused"); + + cy.get("[ui5-shellbar-search]") + .shadow() + .find("input") + .realType("It"); + + cy.get("[ui5-shellbar-search]") + .shadow() + .find("#ui5-search-list") + .ui5ResponsivePopoverOpened(); + + // The application closes the picker from outside + cy.get("[ui5-shellbar-search]") + .then($search => { + ($search.get(0) as ShellBarSearch).open = false; + }); + + // The picker is closed + cy.get("[ui5-shellbar-search]") + .shadow() + .find("#ui5-search-list") + .ui5ResponsivePopoverClosed(); + }); + + it("should open the picker when the application sets 'open' to true while the search is not focused", () => { + cy.mount( + + + + + ); + + // The search is not focused + cy.get("[ui5-shellbar-search]") + .should("not.be.focused"); + + // The picker is initially closed + cy.get("[ui5-shellbar-search]") + .shadow() + .find("#ui5-search-list") + .ui5ResponsivePopoverClosed(); + + // The application opens the picker from outside + cy.get("[ui5-shellbar-search]") + .then($search => { + ($search.get(0) as ShellBarSearch).open = true; + }); + + // The picker is open + cy.get("[ui5-shellbar-search]") + .shadow() + .find("#ui5-search-list") + .ui5ResponsivePopoverOpened(); + }); }); \ No newline at end of file diff --git a/packages/fiori/src/Search.ts b/packages/fiori/src/Search.ts index 2cf15fa3563c6..454d6b8a7ca46 100755 --- a/packages/fiori/src/Search.ts +++ b/packages/fiori/src/Search.ts @@ -237,6 +237,22 @@ class Search extends SearchField { */ _deleteHandler: (e: CustomEvent) => void; + /** + * Tracks whether the most recent change to the `open` property originated + * from the component's internal control logic (through `_setInternalOpen`) + * rather than from the application. Consumed and reset on each reconciliation. + * @private + */ + _openChangedInternally: boolean; + + /** + * Holds the `open` value committed during the previous reconciliation. + * Used together with `_openChangedInternally` to tell an application-driven + * change to `open` apart from a re-render where `open` was not touched. + * @private + */ + _lastOpenState: boolean; + @i18n("@ui5/webcomponents-fiori") static i18nBundle: I18nBundle; @@ -248,6 +264,9 @@ class Search extends SearchField { this._valueBeforeOpen = this.getAttribute("value") || ""; this._isTyping = false; + this._openChangedInternally = false; + this._lastOpenState = this.open; + this._deleteHandler = this._onItemDelete.bind(this); } @@ -256,13 +275,29 @@ class Search extends SearchField { if (this.collapsed && !isPhone()) { this.open = false; + this._lastOpenState = false; + this._openChangedInternally = false; return; } const innerInput = this.nativeInput; const autoCompletedChars = innerInput && (innerInput.selectionEnd! - innerInput.selectionStart!); - this.open = this.open || (this._popoupHasAnyContent() && this._isTyping && innerInput!.value.length > 0); + // The public `open` property is application-controlled and takes higher + // precedence. Internal writes go through `_setInternalOpen`, which raises + // `_openChangedInternally`. A change is treated as application-driven only + // when that flag is not set AND `open` differs from the last committed + // state; that distinguishes an app write from a plain re-render (e.g. when + // lazy-loaded items arrive) where the internal auto-open logic must still run. + const appControlledOpen = !this._openChangedInternally && this.open !== this._lastOpenState; + + if (!appControlledOpen) { + this.open = this.open || (this._popoupHasAnyContent() && this._isTyping && innerInput!.value.length > 0); + } else if (!this.open) { + // The application force-closed the picker; reset the typing state so the + // internal auto-open logic does not immediately reopen it on next render. + this._isTyping = false; + } // If there is already a selection the autocomplete has already been performed if (this._shouldAutocomplete && !autoCompletedChars) { @@ -295,6 +330,12 @@ class Search extends SearchField { item.removeEventListener("ui5-delete", this._deleteHandler as EventListener, true); item.addEventListener("ui5-delete", this._deleteHandler as EventListener, true); }); + + // Commit the resolved open state and clear the internal-change flag so the + // next reconciliation can tell an application-driven change to `open` apart + // from a re-render where `open` was not touched. + this._lastOpenState = this.open; + this._openChangedInternally = false; } onAfterRendering(): void { @@ -403,15 +444,34 @@ class Search extends SearchField { firstListItem?.focus(); } + /** + * Sets the `open` property from internal control logic and flags the change + * as internally driven, so reconciliation can distinguish it from an + * application-driven change to `open`. + * @private + */ + _setInternalOpen(value: boolean) { + // Only flag the change when `open` actually changes. A no-op assignment + // does not invalidate the component (see UI5Element property setter), so + // no reconciliation would run to clear the flag - leaving it stale and + // causing a later application-driven change to be misread as internal. + if (value === this.open) { + return; + } + + this._openChangedInternally = true; + this.open = value; + } + _handleInnerClick() { if (isPhone()) { - this.open = true; + this._setInternalOpen(true); } } _handleSearchIconPress() { if (isPhone()) { - this.open = true; + this._setInternalOpen(true); } else { super._handleSearchIconPress(); } @@ -445,7 +505,7 @@ class Search extends SearchField { } _closePopupAndResetState() { - this.open = false; + this._setInternalOpen(false); this._isTyping = false; this._valueBeforeArrowNav = undefined; } @@ -474,7 +534,7 @@ class Search extends SearchField { } this._isTyping = true; - this.open = this.value.length > 0 && this._popoupHasAnyContent(); + this._setInternalOpen(this.value.length > 0 && this._popoupHasAnyContent()); } _handleClear(): void { @@ -484,7 +544,7 @@ class Search extends SearchField { this._innerValue = ""; this._shouldAutocomplete = false; this._valueBeforeArrowNav = undefined; - this.open = false; + this._setInternalOpen(false); } _popoupHasAnyContent() { @@ -569,7 +629,7 @@ class Search extends SearchField { this._shouldAutocomplete = false; this._performTextSelection = true; this._valueBeforeArrowNav = undefined; - this.open = false; + this._setInternalOpen(false); this._isTyping = false; this.focus(); } @@ -631,7 +691,7 @@ class Search extends SearchField { return; } - this.open = false; + this._setInternalOpen(false); this._isTyping = false; } @@ -648,7 +708,7 @@ class Search extends SearchField { } _handleClose() { - this.open = false; + this._setInternalOpen(false); this._isTyping = false; this._valueBeforeArrowNav = undefined; this.fireDecoratorEvent("close"); diff --git a/packages/fiori/src/ShellBarSearch.ts b/packages/fiori/src/ShellBarSearch.ts index bcf996b325cdc..f4019a63c5c6b 100644 --- a/packages/fiori/src/ShellBarSearch.ts +++ b/packages/fiori/src/ShellBarSearch.ts @@ -111,7 +111,7 @@ class ShellBarSearch extends Search { super._onfocusin(); if (this.autoOpen) { - this.open = true; + this._setInternalOpen(true); this.fireDecoratorEvent("open"); } } diff --git a/packages/fiori/test/pages/ShellBarSearch.html b/packages/fiori/test/pages/ShellBarSearch.html index 162597603feb2..48ef4fcd20956 100644 --- a/packages/fiori/test/pages/ShellBarSearch.html +++ b/packages/fiori/test/pages/ShellBarSearch.html @@ -20,7 +20,7 @@ - @@ -54,6 +54,43 @@ +
+ Open search (from app) + Close search (from app) + +
+ + \ No newline at end of file