diff --git a/packages/main/cypress/specs/MultiInput.mobile.cy.tsx b/packages/main/cypress/specs/MultiInput.mobile.cy.tsx index 62a4f5c35f33..375fe099fa7b 100644 --- a/packages/main/cypress/specs/MultiInput.mobile.cy.tsx +++ b/packages/main/cypress/specs/MultiInput.mobile.cy.tsx @@ -1,6 +1,27 @@ import MultiInput from "../../src/MultiInput.js"; -import ResponsivePopover from "../../src/ResponsivePopover.js"; +import Token from "../../src/Token.js"; +import SuggestionItem from "../../src/SuggestionItem.js"; +import Button from "../../src/Button.js"; import "../../src/features/InputSuggestions.js"; +import type ResponsivePopover from "@ui5/webcomponents/dist/ResponsivePopover.js"; + +const createTokenFromText = (text: string): HTMLElement => { + const token = document.createElement("ui5-token"); + token.setAttribute("text", text); + token.setAttribute("slot", "tokens"); + return token; +}; + +const addTokenToMI = (token: HTMLElement, id: string) => { + document.getElementById(id)?.appendChild(token); +}; + +const handleTokenDelete = (event) => { + const mi = event.target; + event.detail.tokens.forEach(token => { + mi.removeChild(token); + }); +}; describe("Multi Input on mobile device", () => { beforeEach(() => { @@ -18,6 +39,8 @@ describe("Multi Input on mobile device", () => { .as("multiInput"); cy.get("@multiInput") + .shadow() + .find(".ui5-input-inner") .realClick(); cy.get("@multiInput") @@ -44,4 +67,149 @@ describe("Multi Input on mobile device", () => { .should("have.value", "test"); cy.get("@onChange").should("not.have.been.called"); }); + + describe("Filter-Selected Button", () => { + it("Filter-selected button state changes when tokens are added/removed", () => { + cy.mount( + <> + + + + + + Add Token + > + ); + + cy.get("#add-token").then(button => { + button[0].addEventListener("click", () => { + addTokenToMI(createTokenFromText("Test Token"), "test-multi-input"); + }); + }); + + cy.get("#test-multi-input").then(multiInput => { + multiInput[0].addEventListener("ui5-token-delete", handleTokenDelete); + }); + + cy.get("#test-multi-input") + .shadow() + .find(".ui5-input-inner") + .realClick(); + + cy.get("#test-multi-input") + .shadow() + .find("[ui5-responsive-popover]") + .as("popover") + .ui5ResponsivePopoverOpened(); + + // Assert: Button should be initially disabled (no tokens) + cy.get("@popover") + .find("[ui5-toggle-button]") + .as("filterButton") + .should("have.attr", "disabled"); + + cy.get("#test-multi-input") + .shadow() + .find(".ui5-responsive-popover-close-btn") + .realClick(); + + cy.get("#add-token").realClick(); + + cy.get("#test-multi-input") + .find("[ui5-token]") + .should("have.length", 1); + + cy.get("#test-multi-input") + .shadow() + .find(".ui5-input-inner") + .realClick(); + + cy.get("@popover") + .ui5ResponsivePopoverOpened(); + + // Assert: Button should be enabled after adding a token + cy.get("@filterButton") + .should("not.have.attr", "disabled") + cy.get("@filterButton") + .should("have.attr", "pressed"); + + cy.get("@popover") + .find("[ui5-li].ui5-suggestion-token-item") + .first() + .shadow() + .find("[ui5-button]") + .realClick(); + + cy.get("#test-multi-input") + .find("[ui5-token]") + .should("have.length", 0); + + // Assert: Button should be disabled after removing all tokens + cy.get("@filterButton") + .should("have.attr", "disabled"); + }); + + it("Filter-selected button affects list content display", () => { + cy.mount( + + + + + + + + ); + + cy.get("#test-multi-input") + .shadow() + .find(".ui5-input-inner") + .click(); + + cy.get("#test-multi-input") + .shadow() + .find("[ui5-responsive-popover]") + .as("popover") + + cy.get("@popover") + .find("[ui5-toggle-button]") + .as("filterButton"); + + // Assert: Initially showing tokens (button pressed) + cy.get("@filterButton") + .should("have.attr", "pressed"); + + // Assert: Should see token list items + cy.get("@popover") + .find("[ui5-list].ui5-tokenizer-list") + .should("exist"); + + cy.get("@popover") + .find("[ui5-li].ui5-suggestion-token-item") + .should("have.length", 3); + + // Act: Toggle to hide tokens + cy.get("@filterButton").realClick(); + + // Assert: Should see suggestion items instead + cy.get("@popover") + .find("[ui5-list]:not(.ui5-tokenizer-list)") + .should("exist"); + + cy.get("#test-multi-input") + .find("[ui5-suggestion-item]") + .should("have.length", 2); + + // Act: Toggle back to show tokens + cy.get("@filterButton").realClick(); + + // Assert: Should see token list items again + cy.get("@popover") + .find("[ui5-list].ui5-tokenizer-list") + .should("exist"); + + cy.get("@popover") + .find("[ui5-li].ui5-suggestion-token-item") + .should("have.length", 3); + }); + }); }); diff --git a/packages/main/src/InputPopoverTemplate.tsx b/packages/main/src/InputPopoverTemplate.tsx index 1b0fc9534dda..dc1a797e240c 100644 --- a/packages/main/src/InputPopoverTemplate.tsx +++ b/packages/main/src/InputPopoverTemplate.tsx @@ -10,14 +10,20 @@ import PopoverHorizontalAlign from "./types/PopoverHorizontalAlign.js"; import Popover from "./Popover.js"; import ValueState from "@ui5/webcomponents-base/dist/types/ValueState.js"; -export default function InputPopoverTemplate(this: Input, hooks?: { suggestionsList?: (this: Input) => JsxTemplateResult }) { +export default function InputPopoverTemplate(this: Input, hooks?: { suggestionsList?: (this: Input) => JsxTemplateResult, mobileHeader?: (this: Input) => JsxTemplateResult }) { const suggestionsList = hooks?.suggestionsList; + const mobileHeader = hooks?.mobileHeader; return ( <> - {this._effectiveShowSuggestions && this.Suggestions?.template.call(this, { suggestionsList, valueStateMessage, valueStateMessageInputIcon }) } + {this._effectiveShowSuggestions && this.Suggestions?.template.call(this, { + suggestionsList, + mobileHeader, + valueStateMessage, + valueStateMessageInputIcon + })} - {this.hasValueStateMessage && + {this.hasValueStateMessage && ( - { this.valueStateOpen && valueStateMessage.call(this) } + {this.valueStateOpen && valueStateMessage.call(this)} - } + )} > ); } diff --git a/packages/main/src/InputTemplate.tsx b/packages/main/src/InputTemplate.tsx index a5e3c75f98b2..3eae53793cee 100644 --- a/packages/main/src/InputTemplate.tsx +++ b/packages/main/src/InputTemplate.tsx @@ -6,8 +6,9 @@ import InputPopoverTemplate from "./InputPopoverTemplate.js"; type TemplateHook = () => JsxTemplateResult; -export default function InputTemplate(this: Input, hooks?: { preContent: TemplateHook, postContent: TemplateHook, suggestionsList?: TemplateHook }) { +export default function InputTemplate(this: Input, hooks?: { preContent: TemplateHook, postContent: TemplateHook, suggestionsList?: TemplateHook, mobileHeader?: TemplateHook }) { const suggestionsList = hooks?.suggestionsList; + const mobileHeader = hooks?.mobileHeader; const preContent = hooks?.preContent || defaultPreContent; const postContent = hooks?.postContent || defaultPostContent; @@ -118,7 +119,7 @@ export default function InputTemplate(this: Input, hooks?: { preContent: Templat - { InputPopoverTemplate.call(this, { suggestionsList }) } + { InputPopoverTemplate.call(this, { suggestionsList, mobileHeader }) } > ); } diff --git a/packages/main/src/MultiInput.ts b/packages/main/src/MultiInput.ts index ee18c7abf636..1703900e3720 100644 --- a/packages/main/src/MultiInput.ts +++ b/packages/main/src/MultiInput.ts @@ -19,7 +19,12 @@ import { import type { ITabbable } from "@ui5/webcomponents-base/dist/delegate/ItemNavigation.js"; import { getScopedVarName } from "@ui5/webcomponents-base/dist/CustomElementsScope.js"; import type { IFormInputElement } from "@ui5/webcomponents-base/dist/features/InputElementsFormSupport.js"; -import { MULTIINPUT_ROLEDESCRIPTION_TEXT, MULTIINPUT_VALUE_HELP_LABEL, MULTIINPUT_VALUE_HELP } from "./generated/i18n/i18n-defaults.js"; +import { + MULTIINPUT_ROLEDESCRIPTION_TEXT, + MULTIINPUT_VALUE_HELP_LABEL, + MULTIINPUT_VALUE_HELP, + MULTIINPUT_FILTER_BUTTON_LABEL, +} from "./generated/i18n/i18n-defaults.js"; import Input from "./Input.js"; import MultiInputTemplate from "./MultiInputTemplate.js"; import styles from "./generated/themes/MultiInput.css.js"; @@ -123,6 +128,22 @@ class MultiInput extends Input implements IFormInputElement { @property() declare name?: string; + /** + * Indicates whether to show tokens in suggestions popover + * @default false + * @private + */ + @property({ type: Boolean }) + _showTokensInSuggestions = false; + + /** + * Tracks whether user has explicitly toggled the show tokens state + * @default false + * @private + */ + @property({ type: Boolean }) + _userToggledShowTokens = false; + /** * Defines the component tokens. * @public @@ -343,6 +364,23 @@ class MultiInput extends Input implements IFormInputElement { if (this.tokenizer) { this.tokenizer.readonly = this.readonly; } + + // Reset toggle state if there are tokens and dialog is about to open + if (this.tokens.length > 0 && !this._userToggledShowTokens) { + this._showTokensInSuggestions = true; + } + } + + /** + * Override the _handlePickerAfterOpen method to reset toggle state when dialog opens with tokens + */ + _handlePickerAfterOpen() { + if (this.tokens.length > 0) { + this._showTokensInSuggestions = true; + this._userToggledShowTokens = false; + } + + super._handlePickerAfterOpen(); } onAfterRendering() { @@ -371,6 +409,10 @@ class MultiInput extends Input implements IFormInputElement { return MultiInput.i18nBundle.getText(MULTIINPUT_VALUE_HELP); } + get _filterButtonAccessibleName() { + return MultiInput.i18nBundle.getText(MULTIINPUT_FILTER_BUTTON_LABEL); + } + get _tokensCountTextId() { return `hiddenText-nMore`; } @@ -419,6 +461,25 @@ class MultiInput extends Input implements IFormInputElement { get shouldDisplayOnlyValueStateMessage() { return this.hasValueStateMessage && !this.readonly && !this.open && this.focused && !this.tokenizer.open; } + + /** + * Computes the effective state for showing tokens in suggestions. + * Defaults to true when tokens exist, but respects explicit user toggle. + */ + get _effectiveShowTokensInSuggestions() { + // If no tokens exist, always false + if (this.tokens.length === 0) { + return false; + } + + // If user has never interacted with the toggle, default to true when tokens exist + if (!this._userToggledShowTokens) { + return true; + } + + // If user has interacted, respect their choice + return this._showTokensInSuggestions; + } } MultiInput.define(); diff --git a/packages/main/src/MultiInputTemplate.tsx b/packages/main/src/MultiInputTemplate.tsx index 25bbe7cb0e00..03c214d7eb6d 100644 --- a/packages/main/src/MultiInputTemplate.tsx +++ b/packages/main/src/MultiInputTemplate.tsx @@ -1,12 +1,22 @@ import Icon from "./Icon.js"; import InputTemplate from "./InputTemplate.js"; import type MultiInput from "./MultiInput.js"; +import type { MultiInputTokenDeleteEventDetail } from "./MultiInput.js"; import Tokenizer from "./Tokenizer.js"; +import ToggleButton from "./ToggleButton.js"; +import List from "./List.js"; +import ListItemStandard from "./ListItemStandard.js"; +import ListAccessibleRole from "./types/ListAccessibleRole.js"; import valueHelp from "@ui5/webcomponents-icons/dist/value-help.js"; export default function MultiInputTemplate(this: MultiInput) { return [ - InputTemplate.call(this, { preContent, postContent }), + InputTemplate.call(this, { + preContent, + postContent, + suggestionsList: multiInputSuggestionsList, + mobileHeader: multiInputMobileHeader, + }), ]; } @@ -51,3 +61,68 @@ function postContent(this: MultiInput) { > ); } + +function multiInputSuggestionsList(this: MultiInput) { + if (this._effectiveShowTokensInSuggestions) { + return ( + { + const listItem = e.detail.item; + const tokenId = listItem.getAttribute("data-ui5-token-ref-id"); + const token = this.tokens.find((t: any) => t._id === tokenId); + + if (token) { + this.tokenDelete({ detail: { tokens: [token] } } as CustomEvent); + } + }} + > + {this.tokens?.map((token: any, index: number) => ( + + ))} + + ); + } + + return ( + + + + ); +} + +function multiInputMobileHeader(this: MultiInput) { + return ( + { + this._userToggledShowTokens = true; + this._showTokensInSuggestions = !this._effectiveShowTokensInSuggestions; + }} + /> + ); +} diff --git a/packages/main/src/features/InputSuggestionsTemplate.tsx b/packages/main/src/features/InputSuggestionsTemplate.tsx index bd3d917370fa..2b689bcd6771 100644 --- a/packages/main/src/features/InputSuggestionsTemplate.tsx +++ b/packages/main/src/features/InputSuggestionsTemplate.tsx @@ -7,8 +7,10 @@ import ResponsivePopover from "../ResponsivePopover.js"; import Button from "../Button.js"; import ListAccessibleRole from "../types/ListAccessibleRole.js"; -export default function InputSuggestionsTemplate(this: Input, hooks?: { suggestionsList?: (this: Input) => JsxTemplateResult, valueStateMessage: (this: Input) => JsxTemplateResult, valueStateMessageInputIcon: (this: Input) => string }) { +export default function InputSuggestionsTemplate(this: Input, hooks?: { suggestionsList?: (this: Input) => JsxTemplateResult, mobileHeader?: (this: Input) => JsxTemplateResult, valueStateMessage: (this: Input) => JsxTemplateResult, valueStateMessageInputIcon: (this: Input) => string }) { const suggestionsList = hooks?.suggestionsList || defaultSuggestionsList; + // Mobile header hook - intended only for MultiInput design scenario + const mobileHeader = hooks?.mobileHeader; const valueStateMessage = hooks?.valueStateMessage; const valueStateMessageInputIcon = hooks?.valueStateMessageInputIcon; @@ -45,34 +47,35 @@ export default function InputSuggestionsTemplate(this: Input, hooks?: { suggesti placeholder={this.placeholder} onInput={this._handleInput} /> + {mobileHeader?.call(this)} - - {this.hasValueStateMessage && - - - { this.open && valueStateMessage?.call(this) } + {this.hasValueStateMessage && + + + {this.open && valueStateMessage?.call(this)} + + } - } > } {!this._isPhone && this.hasValueStateMessage && - - - { this.open && valueStateMessage?.call(this) } - + + + {this.open && valueStateMessage?.call(this)} + } - { suggestionsList.call(this) } + {suggestionsList.call(this)} {this._isPhone &&