diff --git a/packages/ai/README.md b/packages/ai/README.md index de2a5c96ed4f..4a0de3ffe950 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -16,13 +16,14 @@ Provides web components implementing AI-related visual and interaction. | Button State | `ui5-ai-button-state` | comes with `ui5-ai-button` | | PromptInput | `ui5-ai-prompt-input` | `import "@ui5/webcomponents-ai/dist/PromptInput.js";` | | TextArea | `ui5-ai-textarea` | `import "@ui5/webcomponents-ai/dist/TextArea.js";` | +| Input | `ui5-ai-input` | `import "@ui5/webcomponents-ai/dist/Input.js";` | ## Provided assets | Assets | Module | Notes -|------------|-----------|----------- -| `i18n`, `themes` | `@ui5/webcomponents-ai/dist/Assets.js` | Theming parameters and translations for the components +|------------|-----------|----------- +| `i18n`, `themes` | `@ui5/webcomponents-ai/dist/Assets.js` | Theming parameters and translations for the components ## Resources diff --git a/packages/ai/cypress/specs/Input.cy.tsx b/packages/ai/cypress/specs/Input.cy.tsx new file mode 100644 index 000000000000..52276a3cf553 --- /dev/null +++ b/packages/ai/cypress/specs/Input.cy.tsx @@ -0,0 +1,813 @@ +import Input from "../../src/Input.js"; +import MenuItem from "@ui5/webcomponents/dist/MenuItem.js"; +import "@ui5/webcomponents-icons/dist/ai.js"; +import "@ui5/webcomponents-icons/dist/stop.js"; +import { VERSIONING_NEXT_BUTTON_TOOLTIP, VERSIONING_PREVIOUS_BUTTON_TOOLTIP, INPUT_WRITING_ASSISTANT_LABEL } from "../../src/generated/i18n/i18n-defaults.js"; + +describe("Basic", () => { + describe("Initialization", () => { + it("should render with default properties", () => { + cy.mount(); + + cy.get("[ui5-ai-input]") + .as("input") + .should("exist") + .should("have.prop", "loading", false) + .should("have.prop", "currentVersion", 0) + .should("have.prop", "totalVersions", 0); + }); + + it("should set initial value as a property", () => { + cy.mount(); + + cy.get("[ui5-ai-input]") + .should("have.prop", "value", "AI initial value"); + }); + }); + + describe("Loading States", () => { + it("should display non-loading state correctly", () => { + cy.mount(); + + cy.get("[ui5-ai-input]") + .shadow() + .find("[ui5-busy-indicator]") + .should("not.have.attr", "active"); + }); + + it("should display loading state correctly", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-input]") + .shadow() + .find("[ui5-busy-indicator]") + .should("have.attr", "active"); + }); + + it("should display single result correctly", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-input]") + .should("have.prop", "loading", false) + .should("have.prop", "value", "Generated text") + .should("have.prop", "currentVersion", 1) + .should("have.prop", "totalVersions", 1); + }); + + it("should display multiple results correctly", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-input]") + .should("have.prop", "loading", false) + .should("have.prop", "value", "Generated text") + .should("have.prop", "currentVersion", 2) + .should("have.prop", "totalVersions", 3); + }); + }); + + describe("Version Navigation", () => { + it("should fire version-change event with backwards=true for previous version", () => { + const onVersionChange = cy.spy().as("onVersionChange"); + + cy.mount( + + + ); + + cy.get("[ui5-ai-input]") + .shadow() + .find("input") + .realClick(); + + cy.get("[ui5-ai-input]") + .shadow() + .find('[ui5-menu]') + .as("menu"); + + cy.get("@menu") + .ui5MenuOpen(); + + cy.get("[ui5-ai-input]") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .realClick(); + + cy.get("@onVersionChange") + .should("have.been.calledOnce") + .its("firstCall.args.0.detail") + .should("deep.equal", { + backwards: true + }); + }); + + it("should fire version-change event with backwards=false for next version", () => { + const onVersionChange = cy.spy().as("onVersionChange"); + + cy.mount( + + + ); + + cy.get("[ui5-ai-input]") + .shadow() + .find("input") + .realClick(); + + cy.get("[ui5-ai-input]") + .shadow() + .find('[ui5-menu]') + .as("menu"); + + cy.get("@menu") + .ui5MenuOpen(); + + cy.get("[ui5-ai-input]") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .realClick(); + + cy.get("@onVersionChange") + .should("have.been.calledOnce") + .its("firstCall.args.0.detail") + .should("deep.equal", { + backwards: false, + }); + }); + + it("should disable previous button when at first version", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-input]") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .should("have.attr", "disabled"); + }); + + it("should disable next button when at last version", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-input]") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .should("have.attr", "disabled"); + }); + + it("should sync input content after version navigation", () => { + const initialValue = "Version 1 content"; + const newValue = "Version 2 content"; + const onVersionChange = cy.spy().as("onVersionChange"); + + cy.mount( + + ); + + cy.get("[ui5-ai-input]") + .as("input") + .then($input => { + $input[0].addEventListener("version-change", () => { + const input = $input[0] as Input; + input.value = newValue; + }); + }); + + cy.get("[ui5-ai-input]") + .shadow() + .find('[ui5-menu]') + .as("menu"); + + cy.get("@menu") + .ui5MenuOpen(); + + cy.get("@input") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .realClick(); + + cy.get("@onVersionChange") + .should("have.been.calledOnce"); + + cy.get("@input") + .shadow() + .find("input") + .should("have.value", newValue); + }); + }); + + describe("Menu Integration", () => { + it("should open menu when AI Icon is clicked", () => { + cy.mount( + + + + ); + + cy.get("[ui5-ai-input]") + .realClick(); + + cy.get("[ui5-ai-input]") + .shadow() + .find("[ui5-button]") + .eq(0) + .realClick(); + + cy.get("[ui5-ai-input") + .shadow() + .find("[ui5-menu]") + .ui5MenuOpened(); + }); + }); + + describe("Stop Generation", () => { + it("should fire stop-generation event", () => { + const onStopGeneration = cy.spy().as("onStopGeneration"); + + cy.mount( + + + + ); + + cy.get("[ui5-ai-input]") + .shadow() + .find("input") + .realClick(); + + cy.get("[ui5-ai-input]") + .shadow() + .find("[ui5-button]") + .eq(0) + .realClick(); + + cy.get("@onStopGeneration").should("have.been.calledOnce"); + }); + }); + + describe("Keyboard Shortcuts", () => { + it("should handle Shift+F4 to open menu", () => { + cy.mount( + + + + ); + + cy.get("[ui5-ai-input]") + .shadow() + .find("input") + .focus() + .realPress(['Shift', 'F4']); + + cy.get("[ui5-ai-input]") + .shadow() + .find("[ui5-menu]") + .ui5MenuOpened(); + }); + + it("should handle Ctrl+Shift+Z for previous version when multiple versions exist", () => { + const onVersionChange = cy.spy().as("onVersionChange"); + + cy.mount( + + ); + + cy.get("[ui5-ai-input]") + .shadow() + .find("input") + .focus() + .realPress(['Control', 'Shift', 'Z']); + + cy.get("@onVersionChange") + .should("have.been.calledOnce") + .its("firstCall.args.0.detail") + .should("deep.equal", { + backwards: true + }); + }); + + it("should handle Ctrl+Shift+Y for next version when multiple versions exist", () => { + const onVersionChange = cy.spy().as("onVersionChange"); + + cy.mount( + + ); + + cy.get("[ui5-ai-input]") + .shadow() + .find("input") + .focus() + .realPress(['Control', 'Shift', 'Y']); + + cy.get("@onVersionChange") + .should("have.been.calledOnce") + .its("firstCall.args.0.detail") + .should("deep.equal", { + backwards: false + }); + }); + }); + + describe("Busy State", () => { + it("should show busy indicator when loading", () => { + cy.mount(); + + cy.get("[ui5-ai-input]") + .shadow() + .find("[ui5-busy-indicator]") + .should("have.attr", "active"); + }); + + it("should hide busy indicator when not loading", () => { + cy.mount(); + + cy.get("[ui5-ai-input]") + .shadow() + .find("ui5-busy-indicator") + .should("not.have.attr", "active"); + }); + }); +}); + +describe("Versioning Menu Item", () => { + describe("Initialization", () => { + it("should render with current step and total steps as text", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-input]") + .shadow() + .find("[ui5-menu]") + .ui5MenuOpen(); + + cy.get("[ui5-ai-input") + .shadow() + .find("[ui5-menu-item]") + .as("menuItemVersioning"); + cy.get("@menuItemVersioning") + .should("exist") + .should("have.attr", "text", "2 / 3"); + }); + }); + + describe("Navigation Buttons", () => { + it("should enable both buttons when in middle steps", () => { + cy.mount(); + + cy.get("[ui5-ai-input]") + .shadow() + .find("[ui5-menu]") + .ui5MenuOpen(); + + cy.get("[ui5-ai-input") + .shadow() + .find("[ui5-menu-item]") + .as("menuItemVersioning"); + + cy.get("@menuItemVersioning") + .find('[data-ui5-versioning-button="previous"]') + .should("not.have.attr", "disabled"); + + cy.get("@menuItemVersioning") + .find('[data-ui5-versioning-button="next"]') + .should("not.have.attr", "disabled"); + }); + + it("should have proper icons", () => { + cy.mount(); + + cy.get("[ui5-ai-input]") + .shadow() + .find("[ui5-menu]") + .ui5MenuOpen(); + + cy.get("[ui5-ai-input") + .shadow() + .find("[ui5-menu-item]") + .as("menuItemVersioning"); + + cy.get("@menuItemVersioning") + .find('[data-ui5-versioning-button="previous"]') + .should("have.attr", "icon", "navigation-left-arrow"); + + cy.get("@menuItemVersioning") + .find('[data-ui5-versioning-button="next"]') + .should("have.attr", "icon", "navigation-right-arrow"); + }); + + it("should not fire events when buttons are disabled", () => { + const onVersionChange = cy.spy().as("onVersionChange"); + + cy.mount( + + ); + + + cy.get("[ui5-ai-input]") + .shadow() + .find("[ui5-menu]") + .ui5MenuOpen(); + + cy.get("[ui5-ai-input]") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .as("previousButton") + .should("have.attr", "disabled"); + + cy.get("@previousButton") + .realClick(); + + cy.get("@onVersionChange").should("not.have.been.called"); + }); + + it("should handle multiple rapid clicks gracefully", () => { + const onVersionChange = cy.spy().as("onVersionChange"); + + cy.mount( + + ); + + cy.get("[ui5-ai-input]") + .shadow() + .find("[ui5-menu]") + .ui5MenuOpen(); + + cy.get("[ui5-ai-input]") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .realClick() + .realClick() + .realClick(); + + cy.get("@onVersionChange").should("have.callCount", 3); + + // Verify all calls were for next (backwards: false) + cy.get("@onVersionChange").should((spy) => { + expect(spy).to.have.been.calledWith(Cypress.sinon.match.has("detail", { backwards: false })); + }); + }); + }) + + describe("Focus Management", () => { + it("should manage focus when reaching boundaries", () => { + const onVersionChange = cy.spy().as("onVersionChange"); + + cy.mount( + + ); + + cy.get("[ui5-ai-input]") + .as("input"); + + cy.get("[ui5-ai-input]") + .shadow() + .find("[ui5-menu]") + .ui5MenuOpen(); + + // Test that buttons respond correctly when reaching boundaries + cy.get("@input") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .as("nextButton") + .should("not.have.attr", "disabled"); + + cy.get("@nextButton").realClick(); + + cy.get("@onVersionChange").should("have.been.calledOnce"); + + // Simulate reaching the last step - next button should be disabled + cy.get("@input").invoke("prop", "currentVersion", 3); + + cy.get("@input") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .should("have.attr", "disabled"); + + cy.get("@input") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .as("previousButton") + .should("not.have.attr", "disabled"); + + cy.get("@previousButton").realClick(); + + cy.get("@onVersionChange").should("have.been.calledTwice"); + + // Simulate reaching the first step - previous button should be disabled + cy.get("@input").invoke("prop", "currentVersion", 1); + + cy.get("@input") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .should("have.attr", "disabled"); + + cy.get("@input") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .should("not.have.attr", "disabled"); + }); + + it("should not change focus when buttons remain enabled", () => { + cy.mount(); + + cy.get("[ui5-ai-input]") + .as("input"); + + cy.get("[ui5-ai-input]") + .shadow() + .find("[ui5-menu]") + .ui5MenuOpen(); + + cy.get("@input") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .realClick(); + + // Simulate property change without reaching boundary + cy.get("@input").invoke("prop", "currentVersion", 4); + + // The button should still exist and be enabled + cy.get("@input") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .should("not.have.attr", "disabled"); + }); + }); + + describe("Step Display", () => { + it("should update display when properties change", () => { + cy.mount(); + + cy.get("[ui5-ai-input]") + .as("input") + .invoke("prop", "currentVersion", 2) + .invoke("prop", "totalVersions", 4); + + cy.get("@input") + .shadow() + .find("[ui5-menu]") + .ui5MenuOpen(); + + cy.get("@input") + .shadow() + .find("[ui5-menu-item]") + .should("have.attr", "text", "2 / 4"); + }); + + it("should handle large numbers correctly", () => { + cy.mount(); + + cy.get("[ui5-ai-input]") + .shadow() + .find("[ui5-menu]") + .ui5MenuOpen(); + + cy.get("[ui5-ai-input]") + .shadow() + .find("[ui5-menu-item]") + .should("have.attr", "text", "999 / 1000"); + }); + }); + + describe("Button State Transitions", () => { + it("should handle rapid property changes", () => { + cy.mount(); + + cy.get("[ui5-ai-input]") + .as("input"); + + // Rapidly change properties + for (let i = 1; i <= 5; i++) { + cy.get("@input").invoke("prop", "currentVersion", i); + } + + cy.get("@input") + .shadow() + .find("[ui5-menu]") + .ui5MenuOpen(); + + cy.get("@input") + .shadow() + .find("[ui5-menu-item]") + .should("have.attr", "text", "5 / 5"); + + cy.get("@input") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .should("have.attr", "disabled"); + }); + }); + + describe("Accessibility", () => { + it("should have proper ARIA attributes", () => { + cy.mount(); + + cy.get("[ui5-ai-input]") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .should("have.attr", "design", "Transparent"); + + cy.get("[ui5-ai-input]") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .should("have.attr", "design", "Transparent"); + }); + + + it("should have translatable previous button tooltip", () => { + cy.mount(); + + cy.get("[ui5-ai-input]") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .should("have.attr", "tooltip", VERSIONING_PREVIOUS_BUTTON_TOOLTIP.defaultText); + }); + + it("should have translatable next button tooltip", () => { + cy.mount(); + + cy.get("[ui5-ai-input]") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .should("have.attr", "tooltip", VERSIONING_NEXT_BUTTON_TOOLTIP.defaultText); + }); + }); +}); + +describe("Writing Assistant Input Icon", () => { + it("should not be visible when input is not focused", () => { + cy.mount(); + + cy.get("[ui5-ai-input]") + .shadow() + .find("[ui5-button]") + .eq(0) + .should("not.be.visible"); + }) + it("should render AI Icon on focus", () => { + cy.mount( + + + + ); + + cy.get("[ui5-ai-input]") + .shadow() + .find("input") + .focus(); + + cy.get("[ui5-ai-input]") + .shadow() + .find("[ui5-button]") + .eq(0) + .should("be.visible") + .and("have.prop", "icon", "ai"); + }); + it("should show generating state when loading", () => { + cy.mount(); + + cy.get("[ui5-ai-input]") + .shadow() + .find("[ui5-button]") + .eq(0) + .should("have.prop", "icon", "stop"); + }); + it("should fire icon-click event when clicked in non-loading state", () => { + cy.mount( + + + + ); + + + cy.get("[ui5-ai-input]") + .shadow() + .find("input") + .focus(); + + cy.get("[ui5-ai-input]") + .shadow() + .find("[ui5-button]") + .eq(0) + .realClick(); + + cy.get("@onButtonClick") + .should("have.been.calledOnce"); + }); + + it("should fire stop-generation event when clicked in loading state", () => { + cy.mount( + + + + ); + + cy.get("[ui5-ai-input]") + .shadow() + .find("input") + .realClick(); + + cy.get("[ui5-ai-input]") + .shadow() + .find("[ui5-button]") + .eq(0) + .realClick(); + + cy.get("@onStopGeneration").should("have.been.calledOnce"); + }); + + it("should have proper ariaKeyShortcuts accessibility attribute", () => { + cy.mount(); + + cy.get("[ui5-ai-input]") + .shadow() + .find(".ui5-input-ai-button") + .should("have.attr", "aria-keyshortcuts", "Shift + F4"); + }); + + it("should have correct aria-label attribute", () => { + cy.mount(); + + cy.get("[ui5-ai-input]") + .shadow() + .find("input") + .should("have.attr", "aria-label", INPUT_WRITING_ASSISTANT_LABEL.defaultText); + }); +}) + diff --git a/packages/ai/src/Input.ts b/packages/ai/src/Input.ts new file mode 100644 index 000000000000..25820112457c --- /dev/null +++ b/packages/ai/src/Input.ts @@ -0,0 +1,318 @@ +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 slot from "@ui5/webcomponents-base/dist/decorators/slot.js"; +import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js"; +import { + isEscape, + isF4Shift, +} from "@ui5/webcomponents-base/dist/Keys.js"; +import BaseInput from "@ui5/webcomponents/dist/Input.js"; +import type Menu from "@ui5/webcomponents/dist/Menu.js"; +import type Button from "./Button.js"; + +// styles +import AIInputCss from "./generated/themes/Input.css.js"; +import InputCss from "@ui5/webcomponents/dist/generated/themes/Input.css.js"; +import ResponsivePopoverCommonCss from "@ui5/webcomponents/dist/generated/themes/ResponsivePopoverCommon.css.js"; +import ValueStateMessageCss from "@ui5/webcomponents/dist/generated/themes/ValueStateMessage.css.js"; + +// templates +import InputTemplate from "./InputTemplate.js"; +import { + VERSIONING_NEXT_BUTTON_TEXT, + VERSIONING_PREVIOUS_BUTTON_TEXT, + INPUT_WRITING_ASSISTANT_LABEL, + WRITING_ASSISTANT_GENERATING_ANNOUNCEMENT, +} from "./generated/i18n/i18n-defaults.js"; + +/** + * @class + * + * ### Overview + * + * The `ui5-ai-input` component extends the standard `ui5-input` with **AI Writing Assistant** capabilities. + * + * ### Structure + * + * The `ui5-ai-input` consists of the following main parts: + * + * - **Input Field** – Inherits all standard Input behaviors. + * - **AI Action Button** – Appears when focused or loading, providing access to AI-related actions or stopping generation. + * + * The component automatically determines which elements to render based on its internal state: + * - The AI Button is only shown when there are available `actions`. + * - The version navigation appears only when `totalVersions > 1`. + * + * ### Keyboard Support + * + * - **Shift + F4** — Opens the AI menu. + * - **Ctrl + Shift + Z / Y** — Navigates backward/forward between AI-generated versions. + * + * ### ES6 Module Import + * + * `import "@ui5/webcomponents-ai/dist/Input.js";` + * + * @constructor + * @extends BaseInput + * @since 2.16.0 + * @experimental The **@ui5/webcomponents-ai** package is under active development and considered experimental. Component APIs are subject to change. + * @public + */ +@customElement({ + tag: "ui5-ai-input", + languageAware: true, + renderer: jsxRenderer, + template: InputTemplate, + styles: [ + AIInputCss, + InputCss, + ResponsivePopoverCommonCss, + ValueStateMessageCss, + ], +}) + +/** + * Fired when the user selects the AI button. + * @public + */ +@event("button-click", { + cancelable: true, +}) + +/** + * Fired when the user selects the "Stop" button to stop ongoing AI text generation. + * @public + */ +@event("stop-generation") + +/** + * Fired when the user selects the version navigation buttons. + * + * @param {boolean} backwards - Indicates if navigation is backwards (true) or forwards (false, default) + * @public + */ +@event("version-change") + +class Input extends BaseInput { + eventDetails!: BaseInput["eventDetails"] & { + "version-change": { + backwards: boolean; + }; + "stop-generation": object; + "button-click": object; + }; + + /** + * Indicates the index of the currently displayed version. + * + * @default 0 + * @public + */ + @property({ type: Number }) + currentVersion = 0; + + /** + * Indicates the total number of result versions available. + * + * When not set or set to 0, the versioning will be hidden. + * + * @default 0 + * @public + */ + @property({ type: Number }) + totalVersions = 0; + + /** + * Defines whether the AI Writing Assistant is currently loading. + * + * When `true`, indicates that an AI action is in progress. + * + * @default false + * @public + */ + @property({ type: Boolean }) + loading: boolean = false; + + /** + * Indicates if the menu is open. + * @default 0 + * @private + */ + @property({ type: Boolean }) + _isMenuOpen: boolean = false; + + /** + * Defines the items of the menu for the component. + * @public + */ + @slot({ + type: HTMLElement, + invalidateOnChildChange: true, + }) + actions!: Array; + + _previousCurrentStep = 0; + _previousTotalSteps = 0; + isFocused: boolean = false; + + _onfocusin(e: FocusEvent): void { + super._onfocusin(e); + this.isFocused = true; + } + + _onfocusout(e: FocusEvent): void { + super._onfocusout(e); + this.isFocused = false; + } + + /** + * Manages the focus when the navigation buttons become disabled/enabled. + * Automatically moves the focus to the available button when the user reaches the boundaries. + * @private + */ + _manageVersionButtonsFocus() { + const previousButton = this.shadowRoot?.getElementById("arrow-left") as Button; + const nextButton = this.shadowRoot?.getElementById("arrow-right") as Button; + const isPreviousDisabled = this.currentVersion <= 1; + const isNextDisabled = this.currentVersion >= this.totalVersions; + + if (isPreviousDisabled && previousButton) { + setTimeout(() => { + nextButton.focus(); + }, 0); + } else if (isNextDisabled && nextButton) { + setTimeout(() => { + previousButton.focus(); + }, 0); + } + } + + /** + * Handles the click event for the AI generate Button. + * Fires the appropriate event based on the AI Button state. + * @private + */ + _handleAIButtonClick(e: Event) { + const target = e.target as HTMLElement & { icon?: string }; + if (target?.icon === "stop") { + this.fireDecoratorEvent("stop-generation"); + } else { + const opener = this.shadowRoot?.querySelector(".ui5-input-ai-button") as HTMLElement; + this.fireDecoratorEvent("button-click"); + this.menu.opener = opener; + this.menu.open = true; + this.menu.horizontalAlign = "End"; + } + } + + /** + * Handles the escape event for the AI generate Button. + * @private + */ + _handleAIButtonKeydown = (e: KeyboardEvent) => { + if (isEscape(e) && this.loading) { + this.fireDecoratorEvent("stop-generation"); + } + } + + /** + * Handles the version change event from the versioning component. + * + * @param {CustomEvent} e - The version change event + */ + _handleVersionChange(e: CustomEvent<{ backwards: boolean }>) { + this.fireDecoratorEvent("version-change", { + backwards: e.detail.backwards, + }); + this._manageVersionButtonsFocus(); + } + + /** + * Handles the click event for the "Previous Version" button. + * Updates the current version index and syncs content. + * @private + */ + _handlePreviousButtonClick(): void { + this._handleVersionChange(new CustomEvent("version-change", { detail: { backwards: true } })); + } + + /** + * Handles the click event for the "Next Version" button. + * Updates the current version index and syncs content. + * @private + */ + _handleNextButtonClick(): void { + this._handleVersionChange(new CustomEvent("version-change", { detail: { backwards: false } })); + } + + _onMenuIconClick(): void { + this.menu?.addEventListener("item-click", (e: Event) => { + const customEvent = e as CustomEvent; + this.dispatchEvent(new CustomEvent("item-click", { + detail: customEvent.detail, + bubbles: true, + composed: true, + })); + }); + } + + /** + * Handles keydown events for keyboard shortcuts. + * @private + */ + _onkeydown(e: KeyboardEvent): void { + super._onkeydown(e); + const menuButton = this.shadowRoot?.getElementById("ai-menu-btn") as HTMLElement; + + if (isF4Shift(e)) { + menuButton?.focus(); + + this.menu.opener = menuButton; + this.menu.open = true; + this.menu.horizontalAlign = "End"; + } + const goPreviousStep = e.key === "Z" && e.shiftKey && e.ctrlKey; + const goNextStep = e.key === "Y" && e.shiftKey && e.ctrlKey; + + if (goPreviousStep) { + e.preventDefault(); + this._handlePreviousButtonClick(); + } else if (goNextStep) { + e.preventDefault(); + this._handleNextButtonClick(); + } + } + + /** + * Handles visibility of the Writing Assistant Button. + * If there are no items, the Writing Assistant Button would not be rendered. + */ + get hasActions() { + return !!this?.menu?.getSlottedNodes("items").length; + } + + get ariaLabel() { + return this.accessibleName || !this.loading ? Input.i18nBundle.getText(INPUT_WRITING_ASSISTANT_LABEL) : Input.i18nBundle.getText(WRITING_ASSISTANT_GENERATING_ANNOUNCEMENT); + } + + get stopGeneratingTooltip() { + return Input.i18nBundle.getText(WRITING_ASSISTANT_GENERATING_ANNOUNCEMENT); + } + + get nextButtonAccessibleName() { + return Input.i18nBundle.getText(VERSIONING_NEXT_BUTTON_TEXT); + } + + get previousButtonAccessibleName() { + return Input.i18nBundle.getText(VERSIONING_PREVIOUS_BUTTON_TEXT); + } + + get menu() { + return this.shadowRoot?.querySelector("ui5-menu") as Menu; + } +} + +Input.define(); + +export default Input; diff --git a/packages/ai/src/InputTemplate.tsx b/packages/ai/src/InputTemplate.tsx new file mode 100644 index 000000000000..66987ef3797f --- /dev/null +++ b/packages/ai/src/InputTemplate.tsx @@ -0,0 +1,195 @@ +import type Input from "./Input.js"; +import Icon from "@ui5/webcomponents/dist/Icon.js"; +import BusyIndicator from "@ui5/webcomponents/dist/BusyIndicator.js"; +import MenuItem from "@ui5/webcomponents/dist/MenuItem.js"; +import Button from "@ui5/webcomponents/dist/Button.js"; +import Menu from "@ui5/webcomponents/dist/Menu.js"; +import "@ui5/webcomponents-icons/dist/navigation-left-arrow.js"; +import "@ui5/webcomponents-icons/dist/navigation-right-arrow.js"; +import type { JsxTemplateResult } from "@ui5/webcomponents-base"; + +type TemplateHook = () => JsxTemplateResult; + +export default function InputTemplate(this: Input, hooks?: { preContent: TemplateHook, postContent: TemplateHook }) { + const preContent = hooks?.preContent || defaultPreContent; + const postContent = hooks?.postContent || defaultPostContent; + return ( + <> +
+
+ + +
+
+ {preContent.call(this)} + + + + {this._effectiveShowClearIcon && +
+ + +
+ } + + {this.icon.length > 0 && +
+ +
+ } + +
+ {this._valueStateInputIcon} +
+ { postContent.call(this) } + + {this.accInfo.ariaDescription && + {this.accInfo.ariaDescription} + } + + {this.accInfo.accessibleDescription && + {this.accInfo.accessibleDescription} + } + + {this.linksInAriaValueStateHiddenText.length > 0 && + {this.valueStateLinksShortcutsTextAcc} + } + + {this.hasValueState && + {this.ariaValueStateHiddenText} + } +
+
+ +
+
+
+ + ); +} + +function Versioning(this: Input) { + return ( + <> + + +