diff --git a/packages/main/cypress/specs/ComboBox.cy.tsx b/packages/main/cypress/specs/ComboBox.cy.tsx index 0cd2803c939b..c1cab959a21d 100644 --- a/packages/main/cypress/specs/ComboBox.cy.tsx +++ b/packages/main/cypress/specs/ComboBox.cy.tsx @@ -1,6 +1,7 @@ import ValueState from "@ui5/webcomponents-base/dist/types/ValueState.js"; import ComboBox from "../../src/ComboBox.js"; import ComboBoxItem from "../../src/ComboBoxItem.js"; +import ComboBoxItemCustom from "../../src/ComboBoxItemCustom.js"; import ComboBoxItemGroup from "../../src/ComboBoxItemGroup.js"; import ResponsivePopover from "../../src/ResponsivePopover.js"; import Link from "../../src/Link.js"; @@ -3992,3 +3993,223 @@ describe("Highlighting", () => { .should("contain.html", "AFR"); }); }); + +describe("ComboBoxItemCustom - Rendering", () => { + it("should render custom content correctly", () => { + cy.mount( + + + 🇩🇪 Germany + + + 🇫🇷 France + + + ); + + cy.get("[ui5-combobox]") + .as("combobox") + .shadow() + .find("[ui5-icon]") + .realClick(); + + cy.get("[ui5-cb-item-custom]").eq(0).should("contain.text", "🇩🇪 Germany"); + cy.get("[ui5-cb-item-custom]").eq(1).should("contain.text", "🇫🇷 France"); + }); + + it("should mix regular and custom items", () => { + cy.mount( + + + + Custom Item + + + ); + + cy.get("[ui5-combobox]") + .shadow() + .find("[ui5-icon]") + .realClick(); + + cy.get("[ui5-cb-item]").should("have.length", 1); + cy.get("[ui5-cb-item-custom]").should("have.length", 1); + }); + + it("should have role='option'", () => { + cy.mount( + + Test Item + + ); + + cy.get("[ui5-combobox]") + .shadow() + .find("[ui5-icon]") + .realClick(); + + cy.get("[ui5-cb-item-custom]").shadow().find("li").should("have.attr", "role", "option"); + }); +}); + +describe("ComboBoxItemCustom - Filtering", () => { + it("should filter custom items by text property", () => { + cy.mount( + + 🇩🇪 Germany + 🇫🇷 France + 🇪🇸 Spain + + ); + + cy.get("[ui5-combobox]") + .as("combobox") + .realClick(); + + cy.get("@combobox").realPress("G"); + + cy.get("[ui5-cb-item-custom]").eq(0).should("have.prop", "_isVisible", true); + cy.get("[ui5-cb-item-custom]").eq(1).should("not.have.prop", "_isVisible", true); + cy.get("[ui5-cb-item-custom]").eq(2).should("not.have.prop", "_isVisible", true); + }); + + it("should filter mixed regular and custom items", () => { + cy.mount( + + + 🇩🇪 Germany + + + ); + + cy.get("[ui5-combobox]") + .as("combobox") + .realClick(); + + cy.get("@combobox").realPress("G"); + + cy.get("[ui5-cb-item]").eq(0).should("not.have.prop", "_isVisible", true); + cy.get("[ui5-cb-item-custom]").eq(0).should("have.prop", "_isVisible", true); + cy.get("[ui5-cb-item]").eq(1).should("not.have.prop", "_isVisible", true); + }); +}); + +describe("ComboBoxItemCustom - Selection", () => { + it("should select custom item on click", () => { + cy.mount( + + 🇩🇪 Germany + 🇫🇷 France + + ); + + cy.get("[ui5-combobox]") + .as("combobox") + .shadow() + .find("[ui5-icon]") + .realClick(); + + cy.get("[ui5-cb-item-custom]").eq(0).shadow().find("li").realClick(); + + cy.get("@combobox").should("have.prop", "value", "Germany"); + }); + + it("should select custom item with Enter key", () => { + cy.mount( + + 🇩🇪 Germany + 🇫🇷 France + + ); + + cy.get("[ui5-combobox]") + .as("combobox") + .realClick(); + + cy.get("@combobox").realPress("ArrowDown"); + cy.get("@combobox").realPress("Enter"); + + cy.get("@combobox").should("have.prop", "value", "Germany"); + }); + + it("should work with value property for programmatic selection", () => { + cy.mount( + + 🇩🇪 Germany + 🇫🇷 France + + ); + + cy.get("[ui5-combobox]").should("have.prop", "value", "France"); + cy.get("[ui5-combobox]").should("have.prop", "selectedValue", "FR"); + }); +}); + +describe("ComboBoxItemCustom - Navigation", () => { + it("should navigate through custom items with arrow keys", () => { + cy.mount( + + 🇩🇪 Germany + 🇫🇷 France + 🇪🇸 Spain + + ); + + cy.get("[ui5-combobox]") + .as("combobox") + .shadow() + .find("[ui5-icon]") + .realClick(); + + cy.get("@combobox").shadow().find("input").realPress("ArrowDown"); + cy.get("[ui5-cb-item-custom]").eq(0).should("have.prop", "focused", true); + + cy.get("@combobox").shadow().find("input").realPress("ArrowDown"); + cy.get("[ui5-cb-item-custom]").eq(1).should("have.prop", "focused", true); + + cy.get("@combobox").shadow().find("input").realPress("ArrowUp"); + cy.get("[ui5-cb-item-custom]").eq(0).should("have.prop", "focused", true); + }); + + it("should navigate through mixed items", () => { + cy.mount( + + + 🇩🇪 Germany + + + ); + + cy.get("[ui5-combobox]") + .as("combobox") + .shadow() + .find("[ui5-icon]") + .realClick(); + + cy.get("@combobox").shadow().find("input").realPress("ArrowDown"); + cy.get("[ui5-cb-item]").eq(0).should("have.prop", "focused", true); + + cy.get("@combobox").shadow().find("input").realPress("ArrowDown"); + cy.get("[ui5-cb-item-custom]").eq(0).should("have.prop", "focused", true); + + cy.get("@combobox").shadow().find("input").realPress("ArrowDown"); + cy.get("[ui5-cb-item]").eq(1).should("have.prop", "focused", true); + }); +}); + +describe("ComboBoxItemCustom - Accessibility", () => { + it("should have correct tabindex", () => { + cy.mount( + + Test Item + + ); + + cy.get("[ui5-combobox]") + .shadow() + .find("[ui5-icon]") + .realClick(); + + cy.get("[ui5-cb-item-custom]").shadow().find("li").should("not.have.attr", "tabindex", "0"); + }); +}); diff --git a/packages/main/cypress/specs/MultiComboBox.cy.tsx b/packages/main/cypress/specs/MultiComboBox.cy.tsx index c511b8d25889..7d5add89a3fd 100644 --- a/packages/main/cypress/specs/MultiComboBox.cy.tsx +++ b/packages/main/cypress/specs/MultiComboBox.cy.tsx @@ -1,5 +1,6 @@ import MultiComboBox from "../../src/MultiComboBox.js"; import MultiComboBoxItem from "../../src/MultiComboBoxItem.js"; +import MultiComboBoxItemCustom from "../../src/MultiComboBoxItemCustom.js"; import MultiComboBoxItemGroup from "../../src/MultiComboBoxItemGroup.js"; import ResponsivePopover from "../../src/ResponsivePopover.js"; import Button from "../../src/Button.js"; @@ -5160,3 +5161,320 @@ describe("Validation inside a form", () => { .should("have.been.calledOnce"); }); }); + +describe("MultiComboBoxItemCustom - Rendering", () => { + it("should render custom content correctly", () => { + cy.mount( + + + 🇩🇪 Germany + + + 🇫🇷 France + + + ); + + cy.get("[ui5-multi-combobox]") + .as("multiCombobox") + .shadow() + .find("[ui5-icon]") + .realClick(); + + cy.get("[ui5-mcb-item-custom]").eq(0).should("contain.text", "🇩🇪 Germany"); + cy.get("[ui5-mcb-item-custom]").eq(1).should("contain.text", "🇫🇷 France"); + }); + + it("should render checkbox for custom items", () => { + cy.mount( + + 🇩🇪 Germany + + ); + + cy.get("[ui5-multi-combobox]") + .shadow() + .find("[ui5-icon]") + .realClick(); + + cy.get("[ui5-mcb-item-custom]").shadow().find("[ui5-checkbox]").should("exist"); + }); + + it("should mix regular and custom items", () => { + cy.mount( + + + + Custom Item + + + ); + + cy.get("[ui5-multi-combobox]") + .shadow() + .find("[ui5-icon]") + .realClick(); + + cy.get("[ui5-mcb-item]").should("have.length", 1); + cy.get("[ui5-mcb-item-custom]").should("have.length", 1); + }); +}); + +describe("MultiComboBoxItemCustom - Filtering", () => { + it("should filter custom items by text property", () => { + cy.mount( + + 🇩🇪 Germany + 🇫🇷 France + 🇪🇸 Spain + + ); + + cy.get("[ui5-multi-combobox]") + .as("multiCombobox") + .realClick(); + + cy.get("@multiCombobox").realPress("G"); + + cy.get("[ui5-mcb-item-custom]").eq(0).should("have.prop", "_isVisible", true); + cy.get("[ui5-mcb-item-custom]").eq(1).should("not.have.prop", "_isVisible", true); + cy.get("[ui5-mcb-item-custom]").eq(2).should("not.have.prop", "_isVisible", true); + }); + + it("should filter mixed regular and custom items", () => { + cy.mount( + + + 🇩🇪 Germany + + + ); + + cy.get("[ui5-multi-combobox]") + .as("multiCombobox") + .realClick(); + + cy.get("@multiCombobox").realPress("G"); + + cy.get("[ui5-mcb-item]").eq(0).should("not.have.prop", "_isVisible", true); + cy.get("[ui5-mcb-item-custom]").eq(0).should("have.prop", "_isVisible", true); + cy.get("[ui5-mcb-item]").eq(1).should("not.have.prop", "_isVisible", true); + }); +}); + +describe("MultiComboBoxItemCustom - Selection", () => { + it("should select custom item via checkbox click", () => { + cy.mount( + + 🇩🇪 Germany + 🇫🇷 France + + ); + + cy.get("[ui5-multi-combobox]") + .as("multiCombobox") + .shadow() + .find("[ui5-icon]") + .realClick(); + + cy.get("[ui5-mcb-item-custom]").eq(0).shadow().find("[ui5-checkbox]").realClick(); + + cy.get("[ui5-mcb-item-custom]").eq(0).should("have.prop", "selected", true); + cy.get("@multiCombobox").shadow().find("[ui5-token]").should("have.length", 1); + }); + + it("should select multiple custom items", () => { + cy.mount( + + 🇩🇪 Germany + 🇫🇷 France + 🇪🇸 Spain + + ); + + cy.get("[ui5-multi-combobox]") + .as("multiCombobox") + .shadow() + .find("[ui5-icon]") + .realClick(); + + cy.get("[ui5-mcb-item-custom]").eq(0).shadow().find("[ui5-checkbox]").realClick(); + cy.get("[ui5-mcb-item-custom]").eq(1).shadow().find("[ui5-checkbox]").realClick(); + + cy.get("[ui5-mcb-item-custom]").eq(0).should("have.prop", "selected", true); + cy.get("[ui5-mcb-item-custom]").eq(1).should("have.prop", "selected", true); + cy.get("@multiCombobox").shadow().find("[ui5-token]").should("have.length", 2); + }); + + it("should work with selectedValues property", () => { + cy.mount( + + 🇩🇪 Germany + 🇫🇷 France + 🇪🇸 Spain + + ); + + cy.get("[ui5-mcb-item-custom]").eq(0).should("have.prop", "selected", true); + cy.get("[ui5-mcb-item-custom]").eq(1).should("have.prop", "selected", true); + cy.get("[ui5-mcb-item-custom]").eq(2).should("have.prop", "selected", false); + + cy.get("[ui5-multi-combobox]").shadow().find("[ui5-token]").should("have.length", 2); + }); +}); + +describe("MultiComboBoxItemCustom - Tokens", () => { + it("should display token with text property", () => { + cy.mount( + + 🇩🇪 Germany + + ); + + cy.get("[ui5-multi-combobox]") + .shadow() + .find("[ui5-token]") + .should("have.length", 1) + .and("have.prop", "text", "Germany"); + }); + + it("should display tokens for multiple selected items", () => { + cy.mount( + + 🇩🇪 Germany + 🇫🇷 France + + ); + + cy.get("[ui5-multi-combobox]") + .as("multiCombobox") + .shadow() + .find("[ui5-token]") + .should("have.length", 2); + + cy.get("@multiCombobox").shadow().find("[ui5-token]").eq(0).should("have.prop", "text", "Germany"); + cy.get("@multiCombobox").shadow().find("[ui5-token]").eq(1).should("have.prop", "text", "France"); + }); + + it("should remove selection when token is deleted", () => { + cy.mount( + + 🇩🇪 Germany + 🇫🇷 France + + ); + + cy.get("[ui5-mcb-item-custom]").eq(0).should("have.prop", "selected", true); + + cy.get("[ui5-multi-combobox]") + .shadow() + .find("[ui5-token]") + .eq(0) + .shadow() + .find("[ui5-icon]") + .realClick(); + + cy.get("[ui5-mcb-item-custom]").eq(0).should("have.prop", "selected", false); + cy.get("[ui5-multi-combobox]").shadow().find("[ui5-token]").should("have.length", 0); + }); +}); + +describe("MultiComboBoxItemCustom - Navigation", () => { + it("should navigate through custom items with arrow keys", () => { + cy.mount( + + 🇩🇪 Germany + 🇫🇷 France + 🇪🇸 Spain + + ); + + cy.get("[ui5-multi-combobox]") + .as("multiCombobox") + .realClick(); + + cy.get("@multiCombobox") + .should("be.focused"); + + cy.get("@multiCombobox") + .shadow() + .find("[ui5-icon]") + .realClick(); + + cy.get("@multiCombobox") + .shadow() + .find("ui5-responsive-popover") + .ui5ResponsivePopoverOpened(); + + cy.realPress(["Meta", "ArrowDown"]); + cy.get("[ui5-mcb-item-custom]").eq(0).should("be.focused"); + + cy.realPress(["Meta", "ArrowDown"]); + cy.get("[ui5-mcb-item-custom]").eq(1).should("be.focused"); + + cy.realPress(["Meta", "ArrowUp"]); + cy.get("[ui5-mcb-item-custom]").eq(0).should("be.focused"); + }); + + it("should navigate through mixed items", () => { + cy.mount( + + + 🇩🇪 Germany + + + ); + + cy.get("[ui5-multi-combobox]") + .as("multiCombobox") + .realClick(); + + cy.get("@multiCombobox") + .should("be.focused"); + + cy.get("@multiCombobox") + .shadow() + .find("[ui5-icon]") + .realClick(); + + cy.get("@multiCombobox") + .shadow() + .find("ui5-responsive-popover") + .ui5ResponsivePopoverOpened(); + + cy.realPress(["Meta", "ArrowDown"]); + cy.get("[ui5-mcb-item]").eq(0).should("be.focused"); + + cy.realPress(["Meta", "ArrowDown"]); + cy.get("[ui5-mcb-item-custom]").eq(0).should("be.focused"); + + cy.realPress(["Meta", "ArrowDown"]); + cy.get("[ui5-mcb-item]").eq(1).should("be.focused"); + }); +}); + +describe("MultiComboBoxItemCustom - Mixed Selection", () => { + it("should select both regular and custom items", () => { + cy.mount( + + + 🇩🇪 Germany + + + ); + + cy.get("[ui5-multi-combobox]") + .as("multiCombobox") + .shadow() + .find("[ui5-icon]") + .realClick(); + + cy.get("[ui5-mcb-item]").eq(0).shadow().find("[ui5-checkbox]").realClick(); + cy.get("[ui5-mcb-item-custom]").eq(0).shadow().find("[ui5-checkbox]").realClick(); + + cy.get("[ui5-mcb-item]").eq(0).should("have.prop", "selected", true); + cy.get("[ui5-mcb-item-custom]").eq(0).should("have.prop", "selected", true); + + cy.get("@multiCombobox").shadow().find("[ui5-token]").should("have.length", 2); + }); +}); diff --git a/packages/main/cypress/specs/MultiComboBox.mobile.cy.tsx b/packages/main/cypress/specs/MultiComboBox.mobile.cy.tsx index f5b465c2a9f9..71b644389eeb 100644 --- a/packages/main/cypress/specs/MultiComboBox.mobile.cy.tsx +++ b/packages/main/cypress/specs/MultiComboBox.mobile.cy.tsx @@ -962,4 +962,188 @@ describe("Dialog header title", () => { .find("[ui5-responsive-popover] .ui5-responsive-popover-header-text") .should("have.text", "Country"); }); +}); + +describe("Custom Items", () => { + beforeEach(() => { + cy.ui5SimulateDevice("phone"); + }); + + it("Should select custom items via checkbox click and OK button", () => { + cy.mount( + + + + 🇺🇸 + New York, USA + ✈️ + + + + + 🇬🇧 + London, UK + ✈️ + + + + ); + + cy.get("[ui5-multi-combobox]") + .shadow() + .find("input") + .realClick(); + + // Click custom item checkboxes + cy.get("[ui5-multi-combobox]") + .find("[ui5-mcb-item-custom]") + .eq(0) + .shadow() + .find("[ui5-checkbox]") + .realClick(); + + cy.get("[ui5-multi-combobox]") + .find("[ui5-mcb-item-custom]") + .eq(1) + .shadow() + .find("[ui5-checkbox]") + .realClick(); + + // Press OK button to confirm + cy.get("[ui5-multi-combobox]") + .shadow() + .find("[ui5-responsive-popover]") + .find(".ui5-responsive-popover-footer") + .find("[ui5-button]") + .realClick(); + + // Verify tokens were created + cy.get("[ui5-multi-combobox]") + .shadow() + .find("[ui5-tokenizer]") + .find("[ui5-token]") + .should("have.length", 2); + + // Verify token texts + cy.get("[ui5-multi-combobox]") + .shadow() + .find("[ui5-tokenizer]") + .find("[ui5-token]") + .eq(0) + .shadow() + .find(".ui5-token--text") + .should("have.text", "New York, USA"); + + cy.get("[ui5-multi-combobox]") + .shadow() + .find("[ui5-tokenizer]") + .find("[ui5-token]") + .eq(1) + .shadow() + .find(".ui5-token--text") + .should("have.text", "London, UK"); + }); + + it("Should maintain custom item checkbox state when reopening dialog", () => { + cy.mount( + + + + 🇫🇷 + Paris, France + ✈️ + + + + + 🇯🇵 + Tokyo, Japan + ✈️ + + + + ); + + cy.get("[ui5-multi-combobox]") + .shadow() + .find("input") + .realClick(); + + // Select first custom item and confirm with OK + cy.get("[ui5-multi-combobox]") + .find("[ui5-mcb-item-custom]") + .eq(0) + .shadow() + .find("[ui5-checkbox]") + .realClick(); + + cy.get("[ui5-multi-combobox]") + .shadow() + .find("[ui5-responsive-popover]") + .find(".ui5-responsive-popover-footer") + .find("[ui5-button]") + .realClick(); + + // Reopen the dialog + cy.get("[ui5-multi-combobox]") + .shadow() + .find("input") + .realClick(); + + // Verify checkbox states are maintained + cy.get("[ui5-multi-combobox]") + .find("[ui5-mcb-item-custom]") + .eq(0) + .shadow() + .find("[ui5-checkbox]") + .should("have.attr", "checked"); + + cy.get("[ui5-multi-combobox]") + .find("[ui5-mcb-item-custom]") + .eq(1) + .shadow() + .find("[ui5-checkbox]") + .should("not.have.attr", "checked"); + }); + + it("Should not create token when custom item is selected but Cancel is pressed", () => { + cy.mount( + + + + 🇩🇪 + Berlin, Germany + ✈️ + + + + ); + + cy.get("[ui5-multi-combobox]") + .shadow() + .find("input") + .realClick(); + + // Click custom item checkbox + cy.get("[ui5-multi-combobox]") + .find("[ui5-mcb-item-custom]") + .eq(0) + .shadow() + .find("[ui5-checkbox]") + .realClick(); + + // Press Cancel button + cy.get("[ui5-multi-combobox]") + .shadow() + .find("[ui5-responsive-popover]") + .find(".ui5-responsive-popover-close-btn") + .realClick(); + + // Verify no token was created + cy.get("[ui5-multi-combobox]") + .shadow() + .find("[ui5-tokenizer]") + .find("[ui5-token]") + .should("have.length", 0); + }); }); \ No newline at end of file diff --git a/packages/main/src/ComboBox.ts b/packages/main/src/ComboBox.ts index c7bc268c62c0..f71adbedc173 100644 --- a/packages/main/src/ComboBox.ts +++ b/packages/main/src/ComboBox.ts @@ -79,6 +79,7 @@ import SuggestionsCss from "./generated/themes/Suggestions.css.js"; import "./ComboBoxItem.js"; import type ComboBoxItem from "./ComboBoxItem.js"; +import "./ComboBoxItemCustom.js"; import type Popover from "./Popover.js"; import type ResponsivePopover from "./ResponsivePopover.js"; import type List from "./List.js"; diff --git a/packages/main/src/ComboBoxItemCustom.ts b/packages/main/src/ComboBoxItemCustom.ts new file mode 100644 index 000000000000..ba9a0bd1c9f3 --- /dev/null +++ b/packages/main/src/ComboBoxItemCustom.ts @@ -0,0 +1,80 @@ +import property from "@ui5/webcomponents-base/dist/decorators/property.js"; +import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js"; +import slot from "@ui5/webcomponents-base/dist/decorators/slot-strict.js"; +import type { IComboBoxItem } from "./ComboBox.js"; +import ListItemBase from "./ListItemBase.js"; +import ComboBoxItemCustomTemplate from "./ComboBoxItemCustomTemplate.js"; +import styles from "./generated/themes/ComboBoxItemCustom.css.js"; +import type { DefaultSlot } from "@ui5/webcomponents-base/dist/UI5Element.js"; + +/** + * @class + * The `ui5-cb-item-custom` is type of combobox item, + * that can be used to place combobox items with custom content in the combobox. + * The text property is considered for filtering and autocomplete. + * In case the user needs highlighting functionality, check "@ui5/webcomponents-base/dist/util/generateHighlightedMarkup.js" + * + * @constructor + * @extends ListItemBase + * @implements {IComboBoxItem} + * @public + * @since 2.24.0 + */ +@customElement({ + tag: "ui5-cb-item-custom", + template: ComboBoxItemCustomTemplate, + styles: [ + ListItemBase.styles, + styles, + ], +}) +class ComboBoxItemCustom extends ListItemBase implements IComboBoxItem { + eventDetails!: ListItemBase["eventDetails"]; + + /** + * Defines the text of the component. + * Used for filtering, autocomplete, and mobile rendering. + * @default undefined + * @public + */ + @property() + text?: string; + + /** + * Defines the value of the component. + * Used for programmatic selection via selectedValue property. + * @default undefined + * @public + */ + @property() + value?: string; + + /** + * Indicates whether the item is filtered. + * @private + */ + @property({ type: Boolean, noAttribute: true }) + _isVisible = false; + + /** + * Indicates whether the item is focused. + * @protected + */ + @property({ type: Boolean }) + focused = false; + + /** + * Defines the content of the component. + * @public + */ + @slot({ type: Node, "default": true, invalidateOnChildChange: true }) + content!: DefaultSlot; + + get _effectiveTabIndex() { + return -1; + } +} + +ComboBoxItemCustom.define(); + +export default ComboBoxItemCustom; diff --git a/packages/main/src/ComboBoxItemCustomTemplate.tsx b/packages/main/src/ComboBoxItemCustomTemplate.tsx new file mode 100644 index 000000000000..5c8e2999889c --- /dev/null +++ b/packages/main/src/ComboBoxItemCustomTemplate.tsx @@ -0,0 +1,10 @@ +import ListItemBaseTemplate from "./ListItemBaseTemplate.js"; +import type ComboBoxItemCustom from "./ComboBoxItemCustom.js"; + +export default function ComboBoxItemCustomTemplate(this: ComboBoxItemCustom) { + return ListItemBaseTemplate.call(this, { listItemContent }, { role: "option" }); +} + +function listItemContent(this: ComboBoxItemCustom) { + return ; +} diff --git a/packages/main/src/MultiComboBox.ts b/packages/main/src/MultiComboBox.ts index 085142308936..534eeb34c311 100644 --- a/packages/main/src/MultiComboBox.ts +++ b/packages/main/src/MultiComboBox.ts @@ -59,6 +59,7 @@ import arraysAreEqual from "@ui5/webcomponents-base/dist/util/arraysAreEqual.js" import { submitForm } from "@ui5/webcomponents-base/dist/features/InputElementsFormSupport.js"; import type { IFormInputElement } from "@ui5/webcomponents-base/dist/features/InputElementsFormSupport.js"; import MultiComboBoxItem, { isInstanceOfMultiComboBoxItem } from "./MultiComboBoxItem.js"; +import "./MultiComboBoxItemCustom.js"; import MultiComboBoxItemGroup, { isInstanceOfMultiComboBoxItemGroup } from "./MultiComboBoxItemGroup.js"; import ListItemGroup from "./ListItemGroup.js"; import Tokenizer, { getTokensCountText } from "./Tokenizer.js"; @@ -1622,6 +1623,13 @@ class MultiComboBox extends UI5Element implements IFormInputElement { return this._getItems().filter(item => item.selected) as Array; } + _getSelectedValues(): Array { + return this._getItems() + .filter((i): i is MultiComboBoxItem => isInstanceOfMultiComboBoxItem(i) && i.selected) + .map(i => i.value) + .filter((v): v is string => !!v); + } + _listSelectionChange(e: CustomEvent) { let changePrevented; @@ -1634,16 +1642,15 @@ class MultiComboBox extends UI5Element implements IFormInputElement { this._previouslySelectedItems = e.detail.previouslySelectedItems; } + // Update selectedValues for both desktop and mobile + // On mobile, this provides visual feedback (checkbox state) + // On desktop, this happens before firing the selection-change event + if (this.selectedValues) { + this.selectedValues = this._getSelectedValues(); + } + // don't call selection change right after selection as user can cancel it on phone if (!isPhone()) { - if (this.selectedValues) { - // Get values from all selected items (not just filtered ones) - this.selectedValues = this._getItems() - .filter((i): i is MultiComboBoxItem => isInstanceOfMultiComboBoxItem(i) && i.selected) - .map(i => i.value) - .filter((v): v is string => !!v); - } - changePrevented = this.fireSelectionChange(); if (changePrevented) { @@ -1959,6 +1966,11 @@ class MultiComboBox extends UI5Element implements IFormInputElement { } }); + // Revert selectedValues to match the restored selection state + if (this.selectedValues) { + this.selectedValues = this._getSelectedValues(); + } + this._toggleTokenizerPopover(); this.value = this._valueBeforeOpen; diff --git a/packages/main/src/MultiComboBoxItemCustom.ts b/packages/main/src/MultiComboBoxItemCustom.ts new file mode 100644 index 000000000000..68182e737485 --- /dev/null +++ b/packages/main/src/MultiComboBoxItemCustom.ts @@ -0,0 +1,93 @@ +import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js"; +import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js"; +import i18n from "@ui5/webcomponents-base/dist/decorators/i18n.js"; +import { + property, + eventStrict as event, +} from "@ui5/webcomponents-base/dist/decorators.js"; +import ComboBoxItemCustom from "./ComboBoxItemCustom.js"; +import CheckBox from "./CheckBox.js"; +import type { IMultiComboBoxItem } from "./MultiComboBox.js"; +import { + ARIA_LABEL_LIST_ITEM_CHECKBOX, +} from "./generated/i18n/i18n-defaults.js"; +import styles from "./generated/themes/MultiComboBoxItemCustom.css.js"; +import MultiComboBoxItemCustomTemplate from "./MultiComboBoxItemCustomTemplate.js"; +import type { SelectionRequestEventDetail } from "./ListItem.js"; +import type { AriaRole } from "@ui5/webcomponents-base"; + +/** + * @class + * The `ui5-mcb-item-custom` is type of multi-combobox item, + * that can be used to place multi-combobox items with custom content. + * The text property is considered for filtering and token display. + * In case the user needs highlighting functionality, check "@ui5/webcomponents-base/dist/util/generateHighlightedMarkup.js" + * + * @constructor + * @extends ComboBoxItemCustom + * @implements {IMultiComboBoxItem} + * @public + * @since 2.24.0 + */ +@customElement({ + tag: "ui5-mcb-item-custom", + template: MultiComboBoxItemCustomTemplate, + styles: [ComboBoxItemCustom.styles, styles], + dependencies: [...ComboBoxItemCustom.dependencies, CheckBox], +}) + +@event("selection-requested", { + bubbles: true, +}) +class MultiComboBoxItemCustom extends ComboBoxItemCustom implements IMultiComboBoxItem { + eventDetails!: ComboBoxItemCustom["eventDetails"] & { + "selection-requested": SelectionRequestEventDetail, + } + + /** + * Defines the selected state of the component. + * @default false + * @public + * @deprecated Set the `value` property on items and use the `selectedValues` property on the parent `ui5-multi-combobox` instead for programmatic selection. + */ + @property({ type: Boolean }) + selected = false; + + /** + * @private + */ + @property({ type: Boolean, noAttribute: true }) + _readonly = false; + + @i18n("@ui5/webcomponents") + static i18nBundle: I18nBundle; + + get isMultiComboBoxItem() { + return true; + } + + _onclick(e: MouseEvent) { + if ((e.target as HTMLElement)?.hasAttribute("ui5-checkbox")) { + const checkboxCheckedState = (e.target as CheckBox).checked; + + // The checkbox has already toggled itself, so use its current state + return this.fireDecoratorEvent("selection-requested", { item: this, selected: checkboxCheckedState, selectionComponentPressed: true }); + } + + super._onclick(e); + } + + get _accessibleName() { + return MultiComboBoxItemCustom.i18nBundle.getText(ARIA_LABEL_LIST_ITEM_CHECKBOX); + } + + get checkBoxAccInfo() { + return { + role: "presentation" as AriaRole, + }; + } +} + +MultiComboBoxItemCustom.define(); + +export default MultiComboBoxItemCustom; diff --git a/packages/main/src/MultiComboBoxItemCustomTemplate.tsx b/packages/main/src/MultiComboBoxItemCustomTemplate.tsx new file mode 100644 index 000000000000..f6acf5bd381c --- /dev/null +++ b/packages/main/src/MultiComboBoxItemCustomTemplate.tsx @@ -0,0 +1,22 @@ +import CheckBox from "./CheckBox.js"; +import ListItemBaseTemplate from "./ListItemBaseTemplate.js"; +import type MultiComboBoxItemCustom from "./MultiComboBoxItemCustom.js"; + +export default function MultiComboBoxItemCustomTemplate(this: MultiComboBoxItemCustom) { + return ListItemBaseTemplate.call(this, { listItemContent }, { role: "option" }); +} + +function listItemContent(this: MultiComboBoxItemCustom) { + return ( + <> + + + + + > + ); +} diff --git a/packages/main/src/bundle.esm.ts b/packages/main/src/bundle.esm.ts index f770e818a792..d916b641dfac 100644 --- a/packages/main/src/bundle.esm.ts +++ b/packages/main/src/bundle.esm.ts @@ -47,6 +47,7 @@ import ColorPaletteItem from "./ColorPaletteItem.js"; import ColorPalettePopover from "./ColorPalettePopover.js"; import ColorPicker from "./ColorPicker.js"; import ComboBox from "./ComboBox.js"; +import ComboBoxItemCustom from "./ComboBoxItemCustom.js"; import DatePicker from "./DatePicker.js"; import DateRangePicker from "./DateRangePicker.js"; import DateTimePicker from "./DateTimePicker.js"; @@ -109,6 +110,7 @@ import RangeSlider from "./RangeSlider.js"; import Switch from "./Switch.js"; import MessageStrip from "./MessageStrip.js"; import MultiComboBox from "./MultiComboBox.js"; +import MultiComboBoxItemCustom from "./MultiComboBoxItemCustom.js"; import ProgressIndicator from "./ProgressIndicator.js"; import RatingIndicator from "./RatingIndicator.js"; import Tag from "./Tag.js"; diff --git a/packages/main/src/themes/ComboBoxItemCustom.css b/packages/main/src/themes/ComboBoxItemCustom.css new file mode 100644 index 000000000000..e2ff7ec05922 --- /dev/null +++ b/packages/main/src/themes/ComboBoxItemCustom.css @@ -0,0 +1,14 @@ +:host([ui5-cb-item-custom]) { + height: auto; + min-height: var(--_ui5_list_item_base_height); +} + +:host([ui5-cb-item-custom]) .ui5-li-root { + min-height: var(--_ui5_list_item_base_height); +} + +:host([ui5-cb-item-custom]) .ui5-li-content { + padding-bottom: .5rem; + padding-top: .5rem; + box-sizing: border-box; +} diff --git a/packages/main/src/themes/MultiComboBoxItemCustom.css b/packages/main/src/themes/MultiComboBoxItemCustom.css new file mode 100644 index 000000000000..392e254e3902 --- /dev/null +++ b/packages/main/src/themes/MultiComboBoxItemCustom.css @@ -0,0 +1,19 @@ +:host([ui5-mcb-item-custom]) { + height: auto; + min-height: var(--_ui5_list_item_base_height); +} + +:host([ui5-mcb-item-custom]) .ui5-li-root { + padding-inline-start: 0; + min-height: var(--_ui5_list_item_base_height); +} + +:host([ui5-mcb-item-custom]) .ui5-li-content { + padding-bottom: .5rem; + padding-top: .5rem; + box-sizing: border-box; +} + +:host([ui5-mcb-item-custom]) [ui5-checkbox] { + overflow: visible; +} diff --git a/packages/main/test/pages/ComboBox.html b/packages/main/test/pages/ComboBox.html index 0d2e49ee54cf..0693262cd50b 100644 --- a/packages/main/test/pages/ComboBox.html +++ b/packages/main/test/pages/ComboBox.html @@ -671,6 +671,90 @@ Dialog Header Title from Label + + ComboBox with Custom Items + Custom items with multiple icons/emojis: + + + + + + 🇺🇸 + New York, USA + ✈️ + ⭐ + + + + + 🇬🇧 + London, UK + ✈️ + ⭐ + + + + + 🇯🇵 + Tokyo, Japan + ✈️ + ⭐ + + + + + 🇫🇷 + Paris, France + ✈️ + + + + + 🇩🇪 + Berlin, Germany + 🚆 + + + + + 🇦🇺 + Sydney, Australia + 🏖️ + ⭐ + + + + + Selected value: + + Selected selectedValue: + + + + + + ComboBox with Mixed Items + Regular items mixed with custom items: + + + + + + ⭐ Custom Item with Icon + + + + 🔷 Another Custom Item + + + +