Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions packages/main/cypress/specs/Input.cy.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import Input from "../../src/Input.js";
import InputIcon from "../../src/InputIcon.js";
import SuggestionItem from "../../src/SuggestionItem.js";
import SuggestionItemCustom from "../../src/SuggestionItemCustom.js";
import SuggestionItemGroup from "../../src/SuggestionItemGroup.js";
import Dialog from "../../src/Dialog.js";
import Link from "../../src/Link.js";

import add from "@ui5/webcomponents-icons/dist/add.js";
import "@ui5/webcomponents-icons/dist/search.js";
import "@ui5/webcomponents-icons/dist/microphone.js";
import type ResponsivePopover from "../../src/ResponsivePopover.js";
import { INPUT_SUGGESTIONS_EXPANDED, INPUT_SUGGESTIONS_COLLAPSED, INPUT_SUGGESTIONS_MORE_HITS } from "../../src/generated/i18n/i18n-defaults.js";

Expand Down Expand Up @@ -3607,4 +3610,65 @@ describe("Input built-in filtering", () => {
.should("have.value", "A");
});
});

describe("InputIcon Integration", () => {
it("should render input with interactive icon", () => {
cy.mount(
<Input>
<InputIcon slot="icon" name="search" accessibleName="Search" />
</Input>
);

cy.get("[ui5-input]")
.find("[ui5-input-icon]")
.should("exist");
});

it("should sync value-state to slotted input icons", () => {
cy.mount(
<Input valueState="Negative">
<InputIcon slot="icon" name="search" accessibleName="Search" />
</Input>
);

cy.get("[ui5-input]")
.find("[ui5-input-icon]")
.should("have.attr", "value-state", "Negative");
});

it("should work with multiple icons and clear icon", () => {
cy.mount(
<Input showClearIcon value="test">
<InputIcon slot="icon" name="microphone" accessibleName="Voice" />
<InputIcon slot="icon" name="search" accessibleName="Search" />
</Input>
);

cy.get("[ui5-input]")
.find("[ui5-input-icon]")
.should("have.length", 2);

cy.get("[ui5-input]")
.shadow()
.find("[ui5-icon].ui5-input-clear-icon")
.should("exist");
});

it("should fire click events on interactive icons", () => {
const onClick = cy.spy().as("iconClick");

cy.mount(
<Input>
<InputIcon slot="icon" name="search" accessibleName="Search" onClick={onClick} />
</Input>
);

cy.get("[ui5-input]")
.find("[ui5-input-icon]")
.realClick();

cy.get("@iconClick")
.should("have.been.calledOnce");
});
});
});
9 changes: 9 additions & 0 deletions packages/main/src/Input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -769,6 +769,15 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement
this._effectiveShowClearIcon = (this.showClearIcon && !!this.value && !this.readonly && !this.disabled);
this.style.setProperty("--_ui5-input-icons-count", `${this.iconsCount}`);

// Sync value state to slotted input icons
this.icon.forEach((iconElement: IIcon) => {
if (iconElement.hasAttribute("ui5-input-icon")) {
(iconElement as any).valueState = this.valueState;
(iconElement as any).readonly = this.readonly;
(iconElement as any)._parentDisabled = this.disabled;
}
});

const hasItems = !!this._flattenItems.length;
const hasValue = !!this.value;
const isFocused = this.shadowRoot!.querySelector("input") === getActiveElement();
Expand Down
206 changes: 206 additions & 0 deletions packages/main/src/InputIcon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js";
import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js";
import property from "@ui5/webcomponents-base/dist/decorators/property.js";
import event from "@ui5/webcomponents-base/dist/decorators/event-strict.js";
import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js";
import type ValueState from "@ui5/webcomponents-base/dist/types/ValueState.js";
import { isSpace, isEnter } from "@ui5/webcomponents-base/dist/Keys.js";
import InputIconTemplate from "./InputIconTemplate.js";
import inputIconCss from "./generated/themes/InputIcon.css.js";
import "./Icon.js";

/**
* @class
* ### Overview
* The `ui5-input-icon` component represents an interactive icon that can be placed inside an `ui5-input` component.
* Unlike the standard `ui5-icon`, this component provides button-like behavior with hover, focus, and active states,
* matching the visual style of the input's built-in clear icon.
*
* ### Usage
* Use `ui5-input-icon` for interactive icons that users can click (e.g., search, voice input, camera).
* For decorative icons, use the standard `ui5-icon` component instead.
*
* ### ES6 Module Import
* `import "@ui5/webcomponents/dist/InputIcon.js";`
*
* @constructor
* @extends UI5Element
* @public
* @since 2.25.0
*/
@customElement({
tag: "ui5-input-icon",
renderer: jsxRenderer,
template: InputIconTemplate,
styles: inputIconCss,
languageAware: false,
themeAware: true,
})
/**
* Fired when the `ui5-input-icon` is activated either with a click/tap or by using the Enter or Space key.
* @public
*/
@event("click", {
bubbles: true,
})
class InputIcon extends UI5Element {
eventDetails!: {
click: void;
}

/**
* Defines the icon name to be displayed.
*
* **Note:** Make sure you import the desired icon before using it.
*
* @default undefined
* @public
*/
@property()
name?: string;

/**
* Defines the accessible name of the icon.
*
* **Note:** This property is used for accessibility purposes and will be announced by screen readers.
*
* @default undefined
* @public
*/
@property()
accessibleName?: string;

/**
* Defines whether the tooltip is shown.
*
* **Note:** The tooltip text should be provided via the `accessible-name` property.
*
* @default false
* @public
*/
@property({ type: Boolean })
showTooltip = false;

/**
* Defines the value state of the icon.
*
* **Note:** This property should match the parent input's value state for consistent styling.
*
* @default "None"
* @public
*/
@property()
valueState: `${ValueState}` = "None";

/**
* Defines whether the icon is disabled.
*
* **Note:** Disabled icons are not interactive and do not fire click events.
*
* @default false
* @public
*/
@property({ type: Boolean })
disabled = false;

/**
* Defines whether the icon is readonly.
*
* **Note:** Readonly icons are not interactive and do not fire click events.
*
* @default false
* @public
*/
@property({ type: Boolean })
readonly = false;

/**
* @private
*/
@property({ type: Boolean, noAttribute: true })
_pressed = false;

/**
* @private
*/
@property({ type: Boolean, noAttribute: true })
_focused = false;

/**
* @private
*/
@property({ type: Boolean, noAttribute: true })
_parentDisabled = false;

_onclick(e: MouseEvent) {
if (this.disabled || this.readonly || this._parentDisabled) {
e.stopImmediatePropagation();
e.preventDefault();
return;
}

e.stopImmediatePropagation();
this.fireDecoratorEvent("click");
}

_onmousedown() {
if (!this.disabled && !this.readonly && !this._parentDisabled) {
this._pressed = true;
}
}

_onmouseup() {
this._pressed = false;
}

_onmouseleave() {
this._pressed = false;
}

_onfocus() {
this._focused = true;
}

_onblur() {
this._focused = false;
this._pressed = false;
}

_onkeydown(e: KeyboardEvent) {
if (this.disabled || this.readonly || this._parentDisabled) {
return;
}

if (isEnter(e) || isSpace(e)) {
this._pressed = true;
e.preventDefault(); // Prevent scrolling on Space
}
}

_onkeyup(e: KeyboardEvent) {
if (this.disabled || this.readonly || this._parentDisabled) {
return;
}

if (isEnter(e) || isSpace(e)) {
this._pressed = false;
this.fireDecoratorEvent("click");
}
}

get effectiveTabIndex() {
return (this.disabled || this.readonly || this._parentDisabled) ? -1 : 0;
}

get effectiveAriaLabel() {
return this.accessibleName || undefined;
}

get effectiveTitle() {
return this.showTooltip && this.accessibleName ? this.accessibleName : undefined;
}
}

InputIcon.define();

export default InputIcon;
42 changes: 42 additions & 0 deletions packages/main/src/InputIconTemplate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type InputIcon from "./InputIcon.js";
import Icon from "./Icon.js";

export default function InputIconTemplate(this: InputIcon) {
return (
<>
{!this.readonly &&
<div
class={{
"ui5-input-icon-root": true,
"inputIcon": true,
"inputIcon--pressed": this._pressed,
"inputIcon--focused": this._focused,
"inputIcon--disabled": this.disabled,
}}
role="button"
tabindex={this.effectiveTabIndex}
aria-label={this.effectiveAriaLabel}
aria-disabled={this.disabled}
title={this.effectiveTitle}
onClick={this._onclick}
onMouseDown={this._onmousedown}
onMouseUp={this._onmouseup}
onMouseLeave={this._onmouseleave}
onFocus={this._onfocus}
onBlur={this._onblur}
onKeyDown={this._onkeydown}
onKeyUp={this._onkeyup}
part="root"
>
{this.name && (
<Icon
name={this.name}
class="ui5-input-icon-inner"
aria-hidden="true"
/>
)}
</div>
}
</>
);
}
1 change: 1 addition & 0 deletions packages/main/src/bundle.esm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ import TableRowAction from "./TableRowAction.js";
import TableRowActionNavigation from "./TableRowActionNavigation.js";
import Icon from "./Icon.js";
import Input from "./Input.js";
import InputIcon from "./InputIcon.js";
import SuggestionItemCustom from "./SuggestionItemCustom.js";
import MultiInput from "./MultiInput.js";
import Label from "./Label.js";
Expand Down
4 changes: 4 additions & 0 deletions packages/main/src/themes/Input.css
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,10 @@

/* Input Icon */

::slotted([ui5-input-icon]) {
height: 100%;
}

.ui5-input-icon-root {
min-width: var(--_ui5_input_icon_min_width);
height: 100%;
Expand Down
Loading
Loading