From ce9764c8c0f3b9c05647f33f8f87e50a1c41105f Mon Sep 17 00:00:00 2001 From: i518532 Date: Thu, 20 Nov 2025 10:55:44 +0200 Subject: [PATCH 1/5] feat(ui5-multiinput): implement selected token indicator in filter dialog --- .../cypress/specs/MultiInput.mobile.cy.tsx | 170 +++++++++++++++++- packages/main/src/InputPopoverTemplate.tsx | 5 +- packages/main/src/InputTemplate.tsx | 5 +- packages/main/src/MultiInput.ts | 52 ++++++ packages/main/src/MultiInputTemplate.tsx | 76 +++++++- .../src/features/InputSuggestionsTemplate.tsx | 41 +++-- packages/main/src/themes/Suggestions.css | 4 + 7 files changed, 327 insertions(+), 26 deletions(-) diff --git a/packages/main/cypress/specs/MultiInput.mobile.cy.tsx b/packages/main/cypress/specs/MultiInput.mobile.cy.tsx index 62a4f5c35f33..08a6d03e1dc3 100644 --- a/packages/main/cypress/specs/MultiInput.mobile.cy.tsx +++ b/packages/main/cypress/specs/MultiInput.mobile.cy.tsx @@ -1,7 +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"; +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(() => { cy.ui5SimulateDevice("phone"); @@ -22,7 +42,7 @@ describe("Multi Input on mobile device", () => { cy.get("@multiInput") .shadow() - .find("[ui5-responsive-popover]") + .find("[ui5-responsive-popover]") .as("popover") .ui5ResponsivePopoverOpened(); @@ -44,4 +64,150 @@ 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( + <> + + + + + + + + ); + + // Setup token add functionality + 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); + }); + + // Open suggestions popover + cy.get("#test-multi-input") + .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"); + + // Close popover to add token + cy.get("@popover") + .shadow() + .find(".ui5-responsive-popover-close-btn") + .realClick(); + + // Act: Add a token + cy.get("#add-token").realClick(); + + // Wait for token to be added and re-open popover + cy.get("#test-multi-input") + .find("[ui5-token]") + .should("have.length", 1); + + cy.get("#test-multi-input") + .realClick(); + + cy.get("@popover") + .ui5ResponsivePopoverOpened(); + + // Assert: Button should be enabled after adding a token + cy.get("@filterButton") + .should("not.have.attr", "disabled") + .should("have.attr", "pressed"); + + // Act: Remove the token using the delete icon in the token list + cy.get("@popover") + .find("[ui5-li-standard].ui5-suggestion-token-item") + .first() + .shadow() + .find("[ui5-button]") + .realClick(); + + // Wait for token to be removed + 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( + + + + + + + ); + + // Open suggestions popover + cy.get("#test-multi-input") + .realClick(); + + cy.get("#test-multi-input") + .shadow() + .find("[ui5-responsive-popover]") + .as("popover") + .ui5ResponsivePopoverOpened(); + + 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-standard].ui5-suggestion-token-item") + .should("have.length", 2); + + // 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("@popover") + .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-standard].ui5-suggestion-token-item") + .should("have.length", 2); + }); + }); }); diff --git a/packages/main/src/InputPopoverTemplate.tsx b/packages/main/src/InputPopoverTemplate.tsx index 1b0fc9534dda..aec849aaa10a 100644 --- a/packages/main/src/InputPopoverTemplate.tsx +++ b/packages/main/src/InputPopoverTemplate.tsx @@ -10,12 +10,13 @@ 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 && 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..5d9d2782f445 100644 --- a/packages/main/src/MultiInput.ts +++ b/packages/main/src/MultiInput.ts @@ -123,6 +123,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 +359,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() { @@ -419,6 +452,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..16c0559d7cce 100644 --- a/packages/main/src/MultiInputTemplate.tsx +++ b/packages/main/src/MultiInputTemplate.tsx @@ -2,11 +2,20 @@ import Icon from "./Icon.js"; import InputTemplate from "./InputTemplate.js"; import type MultiInput 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 +60,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 any); + } + }} + > + {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 &&