diff --git a/packages/fiori/cypress/specs/Search.mobile.cy.tsx b/packages/fiori/cypress/specs/Search.mobile.cy.tsx index a20fdd974f6c..75cbd651a5d1 100644 --- a/packages/fiori/cypress/specs/Search.mobile.cy.tsx +++ b/packages/fiori/cypress/specs/Search.mobile.cy.tsx @@ -214,7 +214,7 @@ describe("Search Field on mobile device", () => { .should("have.prop", "value", "initial"); }); - it("should not open when clicking on the scopes select", () => { + it("should not open when clicking on the scopes button", () => { cy.mount( <> @@ -226,16 +226,18 @@ describe("Search Field on mobile device", () => { cy.get("[ui5-search]") .shadow() - .find("[ui5-select]") + .find(".ui5-search-field-scope-button") .realClick(); cy.get("[ui5-search]") .should("have.prop", "open", false); + // The scope popover should open instead cy.get("[ui5-search]") .shadow() - .find("[ui5-select]") - .should("have.prop", "opened", true); + .find("[ui5-busy-indicator]") + .find(".ui5-search-field-scope-popover") + .should("have.attr", "open"); }); it("should accept autocompleted text after pressing go/enter on virtual keyboard", () => { diff --git a/packages/fiori/cypress/specs/SearchField.mobile.cy.tsx b/packages/fiori/cypress/specs/SearchField.mobile.cy.tsx new file mode 100644 index 000000000000..7b5974b55a4a --- /dev/null +++ b/packages/fiori/cypress/specs/SearchField.mobile.cy.tsx @@ -0,0 +1,233 @@ +import SearchField from "../../src/SearchField.js"; +import SearchScope from "../../src/SearchScope.js"; +import { SEARCH_FIELD_SCOPE_SELECT_LABEL } from "../../src/generated/i18n/i18n-defaults.js"; + +describe("SearchField Responsive Scope Selector", () => { + describe("Desktop Mode", () => { + beforeEach(() => { + cy.viewport(1024, 768); + }); + + it("should render Select component on desktop", () => { + cy.mount( + + + + + ); + + cy.get("[ui5-search-field]") + .shadow() + .find("[ui5-select]") + .should("exist"); + + cy.get("[ui5-search-field]") + .shadow() + .find(".ui5-search-field-scope-button") + .should("not.exist"); + }); + + it("should have correct aria-label on Select", () => { + cy.mount( + + + + ); + + cy.get("[ui5-search-field]") + .shadow() + .find("[ui5-select]") + .shadow() + .find("[role='combobox']") + .should("have.attr", "aria-label", SEARCH_FIELD_SCOPE_SELECT_LABEL.defaultText); + }); + }); + + describe("Mobile Mode", () => { + beforeEach(() => { + cy.viewport(400, 600); + }); + + it("should render button instead of Select on mobile", () => { + cy.mount( + + + + + ); + + cy.get("[ui5-search-field]") + .shadow() + .find(".ui5-search-field-scope-button") + .should("exist"); + + cy.get("[ui5-search-field]") + .shadow() + .find("[ui5-select]") + .should("not.exist"); + }); + + it("should show popover when clicking mobile button", () => { + cy.mount( + + + + + ); + + cy.get("[ui5-search-field]") + .shadow() + .find(".ui5-search-field-scope-button") + .realClick(); + + cy.get("[ui5-search-field]") + .shadow() + .find("[ui5-busy-indicator]") + .find(".ui5-search-field-scope-popover") + .should("have.attr", "open"); + }); + + it("should display all scope options in popover", () => { + cy.mount( + + + + + + ); + + cy.get("[ui5-search-field]") + .shadow() + .find(".ui5-search-field-scope-button") + .should("exist") + .realClick(); + + cy.get("[ui5-search-field]") + .shadow() + .find("[ui5-busy-indicator]") + .find("[ui5-responsive-popover]") + .as("popover") + .should("have.attr", "open") + + cy.get("@popover") + .find("[ui5-list]") + .find("[ui5-li]") + .should("have.length", 3); + }); + + it("should mark selected scope in popover", () => { + cy.mount( + + + + + ); + + cy.get("[ui5-search-field]") + .shadow() + .find(".ui5-search-field-scope-button") + .realClick(); + + cy.get("[ui5-search-field]") + .shadow() + .find("[ui5-busy-indicator]") + .find("[ui5-responsive-popover]") + .find("[ui5-list]") + .find("[ui5-li][selected]") + .should("have.length", 1) + .should("contain.text", "Apps"); + }); + + it("should fire scope-change event when selecting from popover", () => { + cy.mount( + + + + + ); + + cy.get("[ui5-search-field]") + .then(searchfield => { + searchfield.get(0).addEventListener("ui5-scope-change", cy.stub().as("scopeChanged")); + }); + + cy.get("[ui5-search-field]") + .shadow() + .find(".ui5-search-field-scope-button") + .realClick(); + + cy.get("[ui5-search-field]") + .shadow() + .find("[ui5-busy-indicator]") + .find("[ui5-responsive-popover]") + .find("[ui5-list]") + .find("[ui5-li]") + .eq(1) + .realClick(); + + cy.get("@scopeChanged") + .should("have.been.calledOnce"); + }); + + it("should close popover after selecting an item", () => { + cy.mount( + + + + + ); + + cy.get("[ui5-search-field]") + .shadow() + .find(".ui5-search-field-scope-button") + .realClick(); + + cy.get("[ui5-search-field]") + .shadow() + .find("[ui5-busy-indicator]") + .find("[ui5-responsive-popover]") + .find("[ui5-list]") + .find("[ui5-li]") + .eq(1) + .realClick(); + + // Popover element is removed from DOM when closed + cy.get("[ui5-search-field]") + .shadow() + .find("[ui5-busy-indicator]") + .find(".ui5-search-field-scope-popover") + .should("not.exist"); + }); + }); + + describe("Responsive Switching", () => { + it("should switch from desktop to mobile on resize", () => { + cy.viewport(1024, 768); + + cy.mount( + + + + + ); + + cy.get("[ui5-search-field]") + .shadow() + .find("[ui5-select]") + .should("exist"); + + cy.viewport(400, 600); + cy.wait(100); + + cy.get("[ui5-search-field]") + .shadow() + .find(".ui5-search-field-scope-button") + .should("exist"); + + cy.get("[ui5-search-field]") + .shadow() + .find("[ui5-select]") + .should("not.exist"); + }); + }); +}); diff --git a/packages/fiori/src/SearchField.ts b/packages/fiori/src/SearchField.ts index c64fe8338cfb..b0a3b5cf3e76 100644 --- a/packages/fiori/src/SearchField.ts +++ b/packages/fiori/src/SearchField.ts @@ -10,6 +10,8 @@ import SearchFieldCss from "./generated/themes/SearchField.css.js"; import type Button from "@ui5/webcomponents/dist/Button.js"; import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js"; import type { IOption, SelectChangeEventDetail } from "@ui5/webcomponents/dist/Select.js"; +import { isPhone } from "@ui5/webcomponents-base/dist/Device.js"; +import type { ListItemClickEventDetail } from "@ui5/webcomponents/dist/List.js"; import { isEnter, @@ -21,8 +23,11 @@ import { SEARCH_FIELD_CLEAR_ICON, SEARCH_FIELD_SEARCH_ICON, SEARCH_FIELD_LABEL, + SEARCH_FIELD_PLACEHOLDER_WITH_SCOPE, } from "./generated/i18n/i18n-defaults.js"; +const SCREEN_WIDTH_BREAKPOINT = 450; + /** * Interface for components that may be slotted inside a `ui5-search` * @public @@ -141,6 +146,11 @@ class SearchField extends UI5Element { /** * Defines a short hint intended to aid the user with data entry when the * component has no value. + * + * **Note:** When `scopes` are defined and no custom placeholder is provided, + * the placeholder automatically displays "Search in: \{selected scope name\}". + * Setting a custom placeholder will override this automatic behavior. + * * @default undefined * @public */ @@ -207,13 +217,57 @@ class SearchField extends UI5Element { @property({ type: Boolean }) _effectiveShowClearIcon = false; + /** + * Indicates whether the component is rendering on a small screen (mobile). + * @private + */ + @property({ type: Boolean }) + _isMobileView = false; + + /** + * Indicates whether the scope selection popover is open on mobile. + * @private + */ + @property({ type: Boolean }) + _scopePopoverOpen = false; + + _scopeIconButton?: HTMLElement; + _resizeHandler?: () => void; + @i18n("@ui5/webcomponents-fiori") static i18nBundle: I18nBundle; + onEnterDOM() { + this._resizeHandler = this._handleResize.bind(this); + window.addEventListener("resize", this._resizeHandler); + this._isMobileView = this._isSmallScreen(); + } + + onExitDOM() { + if (this._resizeHandler) { + window.removeEventListener("resize", this._resizeHandler); + } + } + onBeforeRendering() { this._effectiveShowClearIcon = (this.showClearIcon && !!this.value); } + private _isSmallScreen(): boolean { + return isPhone() || window.innerWidth < SCREEN_WIDTH_BREAKPOINT; + } + + private _handleResize() { + const newMobileView = this._isSmallScreen(); + if (this._isMobileView !== newMobileView) { + this._isMobileView = newMobileView; + // Close popover when switching modes to prevent state issues + if (this._scopePopoverOpen) { + this._scopePopoverOpen = false; + } + } + } + _onkeydown(e:KeyboardEvent) { if (isEnter(e)) { return this._handleEnter(); @@ -276,6 +330,36 @@ class SearchField extends UI5Element { }); } + _handleScopeIconPress() { + if (!this.scopes?.length) { + return; + } + this._scopePopoverOpen = !this._scopePopoverOpen; + } + + _handleScopePopoverClose() { + this._scopePopoverOpen = false; + } + + _handleScopeItemClick(e: CustomEvent) { + const listItem = e.detail.item; + if (!listItem) { + return; + } + + const scopeValue = listItem.getAttribute("data-scope-value"); + const scopeItem = this.scopes.find((scope: ISearchScope) => scope.value === scopeValue); + + if (scopeItem) { + this.scopeValue = scopeItem.value; + this.fireDecoratorEvent("scope-change", { + scope: scopeItem, + }); + } + + this._scopePopoverOpen = false; + } + get _isSearchIcon() { return this.value.length && this.focusedInnerInput; } @@ -292,9 +376,29 @@ class SearchField extends UI5Element { searchIcon: SearchField.i18nBundle.getText(SEARCH_FIELD_SEARCH_ICON), clearIcon: SearchField.i18nBundle.getText(SEARCH_FIELD_CLEAR_ICON), searchFieldAriaLabel: SearchField.i18nBundle.getText(SEARCH_FIELD_LABEL), + placeholderWithScope: SearchField.i18nBundle.getText(SEARCH_FIELD_PLACEHOLDER_WITH_SCOPE), }; } + get _effectivePlaceholder(): string | undefined { + // If scopes exist and no user-defined placeholder, show "Search in: {SCOPE}" + if (this.scopes?.length && !this.placeholder && this.scopeValue) { + const selectedScope = this.scopes.find((scope: ISearchScope) => scope.value === this.scopeValue); + if (selectedScope?.text) { + return String(SearchField.i18nBundle.getText(SEARCH_FIELD_PLACEHOLDER_WITH_SCOPE, String(selectedScope.text))); + } + } + return this.placeholder; + } + + get _scopeIconAccessibleName(): string { + const selectedScope = this.scopes.find((scope: ISearchScope) => scope.value === this.scopeValue); + + return selectedScope + ? `${this._translations.scope}, ${selectedScope.text}` + : this._translations.scope; + } + get _effectiveIconTooltip() { return this._translations.searchIcon; } @@ -304,6 +408,12 @@ class SearchField extends UI5Element { ref.scopeOption = this; } } + + captureScopeIconRef(ref: HTMLElement | null) { + if (ref) { + this._scopeIconButton = ref; + } + } } SearchField.define(); diff --git a/packages/fiori/src/SearchFieldScopePopoverTemplate.tsx b/packages/fiori/src/SearchFieldScopePopoverTemplate.tsx new file mode 100644 index 000000000000..ec571681d542 --- /dev/null +++ b/packages/fiori/src/SearchFieldScopePopoverTemplate.tsx @@ -0,0 +1,41 @@ +import ResponsivePopover from "@ui5/webcomponents/dist/ResponsivePopover.js"; +import List from "@ui5/webcomponents/dist/List.js"; +import ListItemStandard from "@ui5/webcomponents/dist/ListItemStandard.js"; +import ListSeparator from "@ui5/webcomponents/dist/types/ListSeparator.js"; +import PopoverPlacement from "@ui5/webcomponents/dist/types/PopoverPlacement.js"; +import PopoverHorizontalAlign from "@ui5/webcomponents/dist/types/PopoverHorizontalAlign.js"; +import type SearchField from "./SearchField.js"; + +export default function SearchFieldScopePopoverTemplate(this: SearchField) { + if (!this._isMobileView || !this.scopes?.length || !this._scopePopoverOpen) { + return null; + } + + return ( + + + {this.scopes.map(scopeOption => ( + + {scopeOption.text} + + ))} + + + ); +} diff --git a/packages/fiori/src/SearchFieldTemplate.tsx b/packages/fiori/src/SearchFieldTemplate.tsx index 241d9978f4c9..160ab6994560 100644 --- a/packages/fiori/src/SearchFieldTemplate.tsx +++ b/packages/fiori/src/SearchFieldTemplate.tsx @@ -5,8 +5,10 @@ import Select from "@ui5/webcomponents/dist/Select.js"; import type SearchField from "./SearchField.js"; import decline from "@ui5/webcomponents-icons/dist/decline.js"; import search from "@ui5/webcomponents-icons/dist/search.js"; +import slimArrowDown from "@ui5/webcomponents-icons/dist/slim-arrow-down.js"; import ButtonDesign from "@ui5/webcomponents/dist/types/ButtonDesign.js"; import BusyIndicator from "@ui5/webcomponents/dist/BusyIndicator.js"; +import SearchFieldScopePopoverTemplate from "./SearchFieldScopePopoverTemplate.js"; export type SearchFieldTemplateOptions = { /** @@ -35,23 +37,46 @@ export default function SearchFieldTemplate(this: SearchField, options?: SearchF
{this.scopes?.length ? ( <> - -
+ {!this._isMobileView ? ( + // Desktop/Tablet: Show full Select component + <> + +
+ + ) : ( + // Mobile/Phone: Show icon button only + <> +
+ {SearchFieldScopePopoverTemplate.call(this)} ) ); diff --git a/packages/fiori/src/i18n/messagebundle.properties b/packages/fiori/src/i18n/messagebundle.properties index 2397251013b9..7a7d505ed443 100644 --- a/packages/fiori/src/i18n/messagebundle.properties +++ b/packages/fiori/src/i18n/messagebundle.properties @@ -503,6 +503,9 @@ SEARCH_FIELD_SCOPE_SELECT_LABEL=Select scope #XACT: ARIA label for search field SEARCH_FIELD_LABEL=Search field +#XFLD: Placeholder text for search field with scope on mobile +SEARCH_FIELD_PLACEHOLDER_WITH_SCOPE=Search in: {0} + #XACT: ARIA announcement for search field clear icon SEARCH_FIELD_CLEAR_ICON=Clear search diff --git a/packages/fiori/src/themes/SearchField.css b/packages/fiori/src/themes/SearchField.css index 712a65b1e7dc..8168ec3205e0 100644 --- a/packages/fiori/src/themes/SearchField.css +++ b/packages/fiori/src/themes/SearchField.css @@ -8,6 +8,7 @@ :host(:not([collapsed])), .ui5-shellbar-search-field-wrapper { + width: var(--search_width); min-width: 18rem; max-width: 36rem; margin: 0; @@ -76,8 +77,11 @@ /* scope select */ [ui5-select] { outline: none; - margin: var(--_ui5_search_input_scope_margin); - max-width: 10rem; + margin: 0 0.25rem; + width: fit-content; + min-width: auto; + max-width: 18rem; + flex: 0 1 auto; border-radius: var(--_ui5_search_input_border_radius); border: var(--_ui5-search-border); box-shadow: none; @@ -298,4 +302,50 @@ .ui5-search-field-inner-input::selection { background: var(--sapSelectedColor); color: var(--sapContent_ContrastTextColor); -} \ No newline at end of file +} + +/* Mobile scope selector button */ +.ui5-search-field-scope-button { + min-width: 2rem; + width: 2rem; + height: var(--_ui5-search-select-height); + margin: 0 0.25rem; + border-radius: var(--_ui5_search_input_border_radius); + border: var(--_ui5-search-border); + box-shadow: none; + background: var(--_ui5-search-elements-background); + color: var(--sapShell_InteractiveTextColor); +} + +.ui5-search-field-scope-button::part(button)::before, +.ui5-search-field-scope-button::part(button)::after { + display: none; +} + +.ui5-search-field-scope-button:hover { + box-shadow: var(--_ui5-search_input_scope_hover_shadow); + background-color: var(--sapShell_Hover_Background); +} + +.ui5-search-field-scope-button:focus-within { + box-shadow: var(--_ui5-search_input_scope_active_shadow); + background: var(--sapShell_Active_Background); + color: var(--sapShell_Active_TextColor); +} + +/* Scope popover on mobile */ +.ui5-search-field-scope-popover { + min-width: 12rem; +} + +.ui5-search-field-scope-popover::part(content) { + padding: 0; +} + +.ui5-search-field-scope-popover [ui5-list] { + border: none; +} + +.ui5-search-field-scope-popover [ui5-li-standard][selected] { + background-color: var(--sapList_SelectionBackgroundColor); +} diff --git a/packages/fiori/test/pages/Search.html b/packages/fiori/test/pages/Search.html index e3aada6bf781..58b08158dbe4 100644 --- a/packages/fiori/test/pages/Search.html +++ b/packages/fiori/test/pages/Search.html @@ -426,6 +426,8 @@ { name: 'Manage Products', scope: 'apps' }, { name: 'Mobile Phones', scope: 'products' }, { name: 'Tablet', scope: 'products' }, + { name: 'Long Scope', scope: 'long' }, + { name: 'Very long scope', scope: 'long' }, ]; function createScopeItems(scope) { @@ -584,5 +586,21 @@ } }); + +
+ Search with Long Scope Labels - Testing scope dropdown with extensive text + + + + + + + + + + This example demonstrates how the scope dropdown handles long text labels and proper truncation/wrapping behavior. + +
+ diff --git a/packages/fiori/test/pages/ShellBarSearch.html b/packages/fiori/test/pages/ShellBarSearch.html index 1198e28c6e85..162597603feb 100644 --- a/packages/fiori/test/pages/ShellBarSearch.html +++ b/packages/fiori/test/pages/ShellBarSearch.html @@ -32,6 +32,27 @@ + + + + + Enterprise Portal + + + + + + + + + + + + + + + + diff --git a/packages/fiori/test/pages/ShellBar_Features.html b/packages/fiori/test/pages/ShellBar_Features.html index 3aa065281fa8..5538fd982cca 100644 --- a/packages/fiori/test/pages/ShellBar_Features.html +++ b/packages/fiori/test/pages/ShellBar_Features.html @@ -513,6 +513,40 @@

ShellBar Feature Toggle

newEl.setAttribute('placeholder', 'Search...'); newEl.setAttribute('collapsed', ''); + // Add scope options + const scope1 = document.createElement('ui5-search-scope'); + scope1.setAttribute('text', 'All'); + scope1.setAttribute('value', 'all'); + scope1.setAttribute('slot', 'scopes'); + + const scope2 = document.createElement('ui5-search-scope'); + scope2.setAttribute('text', 'Enterprise Resource Planning'); + scope2.setAttribute('value', 'erp'); + scope2.setAttribute('slot', 'scopes'); + + const scope3 = document.createElement('ui5-search-scope'); + scope3.setAttribute('text', 'Customer Relationship Management and Marketing Automation'); + scope3.setAttribute('value', 'crm'); + scope3.setAttribute('slot', 'scopes'); + + const scope4 = document.createElement('ui5-search-scope'); + scope4.setAttribute('text', 'Supply Chain Management, Logistics, and Warehouse Operations'); + scope4.setAttribute('value', 'scm'); + scope4.setAttribute('slot', 'scopes'); + + const scope5 = document.createElement('ui5-search-scope'); + scope5.setAttribute('text', 'HR'); + scope5.setAttribute('value', 'hr'); + scope5.setAttribute('slot', 'scopes'); + + newEl.appendChild(scope1); + newEl.appendChild(scope2); + newEl.appendChild(scope3); + newEl.appendChild(scope4); + newEl.appendChild(scope5); + + newEl.setAttribute('scope-value', 'all'); + replaceSearchField('searchField', newEl); } else { // Create ui5-input element diff --git a/packages/main/src/SelectTemplate.tsx b/packages/main/src/SelectTemplate.tsx index dbcffdad1f0d..9998106d3324 100644 --- a/packages/main/src/SelectTemplate.tsx +++ b/packages/main/src/SelectTemplate.tsx @@ -24,6 +24,7 @@ export default function SelectTemplate(this: Select) {