From 4d676e09f98077346f2b4cae2271f2432f478fb3 Mon Sep 17 00:00:00 2001 From: Nikolay Deshev Date: Wed, 20 Aug 2025 06:39:00 +0300 Subject: [PATCH 01/41] feat(ui5-ai-textarea): introduce new component initial poc draft, all naming and logic are work in progress and subject to change --- packages/ai/src/AITextArea.ts | 203 ++++++ packages/ai/src/AITextAreaTemplate.tsx | 88 +++ .../ai/src/AiWritingAssistantTemplate.tsx | 25 + packages/ai/src/AiWritingAssistantToolbar.ts | 173 +++++ .../src/AiWritingAssistantToolbarTemplate.tsx | 50 ++ packages/ai/src/Version.ts | 90 +++ packages/ai/src/VersionTemplate.tsx | 39 ++ packages/ai/src/bundle.esm.ts | 1 + packages/ai/src/themes/AITextArea.css | 52 ++ packages/ai/src/themes/Version.css | 20 + packages/ai/src/types/AssistantState.ts | 39 ++ packages/ai/test/pages/AITextArea.html | 629 ++++++++++++++++++ packages/main/src/TextAreaTemplate.tsx | 63 +- 13 files changed, 1449 insertions(+), 23 deletions(-) create mode 100644 packages/ai/src/AITextArea.ts create mode 100644 packages/ai/src/AITextAreaTemplate.tsx create mode 100644 packages/ai/src/AiWritingAssistantTemplate.tsx create mode 100644 packages/ai/src/AiWritingAssistantToolbar.ts create mode 100644 packages/ai/src/AiWritingAssistantToolbarTemplate.tsx create mode 100644 packages/ai/src/Version.ts create mode 100644 packages/ai/src/VersionTemplate.tsx create mode 100644 packages/ai/src/themes/AITextArea.css create mode 100644 packages/ai/src/themes/Version.css create mode 100644 packages/ai/src/types/AssistantState.ts create mode 100644 packages/ai/test/pages/AITextArea.html diff --git a/packages/ai/src/AITextArea.ts b/packages/ai/src/AITextArea.ts new file mode 100644 index 000000000000..636c32c03d4e --- /dev/null +++ b/packages/ai/src/AITextArea.ts @@ -0,0 +1,203 @@ +import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js"; +import property from "@ui5/webcomponents-base/dist/decorators/property.js"; +import slot from "@ui5/webcomponents-base/dist/decorators/slot.js"; +import event from "@ui5/webcomponents-base/dist/decorators/event-strict.js"; +import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js"; +import getEffectiveScrollbarStyle from "@ui5/webcomponents-base/dist/util/getEffectiveScrollbarStyle.js"; + +import TextArea from "@ui5/webcomponents/dist/TextArea.js"; +import BusyIndicator from "@ui5/webcomponents/dist/BusyIndicator.js"; +import type AssistantState from "./types/AssistantState.js"; + +// Styles +import AITextAreaCss from "./generated/themes/AITextArea.css.js"; +import textareaStyles from "@ui5/webcomponents/dist/generated/themes/TextArea.css.js"; +import valueStateMessageStyles from "@ui5/webcomponents/dist/generated/themes/ValueStateMessage.css.js"; + +// Templates +import AITextAreaTemplate from "./AITextAreaTemplate.js"; +import AiWritingAssistantToolbar from "./AiWritingAssistantToolbar.js"; +import Version from "./Version.js"; + +/** + * @class + * + * ### Overview + * + * The `ui5-ai-textarea` component extends the standard TextArea with AI Writing Assistant capabilities. + * It provides AI-powered text generation, editing suggestions, and version management functionality. + * + * ### Structure + * The `ui5-ai-textarea` consists of the following elements: + * - TextArea: The main text input area with all standard textarea functionality + * - AI Toolbar: Specialized toolbar with AI generation controls + * - Version Navigation: Controls for navigating between AI-generated versions + * - Menu Integration: Support for AI action menu + * + * ### States + * The `ui5-ai-textarea` supports multiple states: + * - Default: Shows only the AI button + * - Loading: Indicates AI generation in progress + * - SingleResult: Shows result with action label + * - MultipleResults: Shows result with version navigation + * + * ### ES6 Module Import + * + * `import "@sap-webcomponents/ai/dist/AITextArea.js";` + * + * @constructor + * @extends TextArea + * @since 1.0.0-rc.1 + * @public + * @slot {HTMLElement} menu Defines a slot for `ui5-menu` integration. This slot allows you to pass a `ui5-menu` instance that will be associated with the assistant. + */ +@customElement({ + tag: "ui5-ai-textarea", + languageAware: true, + renderer: jsxRenderer, + template: AITextAreaTemplate, + styles: [ + textareaStyles, + valueStateMessageStyles, + getEffectiveScrollbarStyle(), + AITextAreaCss, + ], + dependencies: [ + AiWritingAssistantToolbar, + Version, + BusyIndicator, + ], +}) + +/** + * Fired when the user clicks on the "Previous Version" button. + * + * @public + */ +@event("previous-version-click") + +/** + * Fired when the user clicks on the "Next Version" button. + * + * @public + */ +@event("next-version-click") + +/** + * Fired when the user requests to stop AI text generation. + * + * @public + */ +@event("stop-generation") + +class AITextArea extends TextArea { + eventDetails!: TextArea["eventDetails"] & { + "previous-version-click": object; + "next-version-click": object; + "stop-generation": object; + }; + + /** + * Defines the current state of the AI Writing Assistant. + * + * Available values are: + * - `"Default"`: Shows only the main toolbar button. + * - `"Loading"`: Indicates that an action is in progress. + * - `"SingleResult"`: A single result is displayed. + * - `"MultipleResults"`: Multiple results are displayed. + * + * @default "Default" + * @public + */ + @property() + assistantState: `${AssistantState}` = "Default"; + + /** + * Defines the action text of the AI Writing Assistant. + * + * @default "" + * @public + */ + @property() + actionText = ""; + + /** + * Indicates the index of the currently displayed result version. + * + * The index is **1-based** (i.e. `1` represents the first result). + * + * @default 1 + * @public + */ + @property({ type: Number }) + currentVersionIndex = 1; + + /** + * Indicates the total number of result versions available. + * + * When not set or `0`, versioning UI will be hidden. + * + * @default 0 + * @public + */ + @property({ type: Number }) + totalVersions = 0; + + @slot({ type: HTMLElement }) + menu!: Array; + + /** + * Handles the click event for the "Previous Version" button. + */ + _handlePreviousVersionClick() { + this.fireDecoratorEvent("previous-version-click"); + } + + /** + * Handles the click event for the "Next Version" button. + */ + _handleNextVersionClick() { + this.fireDecoratorEvent("next-version-click"); + } + + /** + * Handles the generate click event from the AI toolbar. + * Opens the AI menu and sets the opener element. + * + * @private + */ + handleGenerateClick = (e: CustomEvent<{ clickTarget?: HTMLElement }>) => { + try { + const menuNodes = this.getSlottedNodes("menu"); + if (menuNodes.length > 0 && e.detail?.clickTarget) { + const menu = menuNodes[0] as HTMLElement & { opener?: HTMLElement; open?: boolean }; + if (menu && typeof menu.open !== "undefined") { + menu.opener = e.detail.clickTarget; + menu.open = true; + } + } + } catch (error) { + // eslint-disable-next-line no-console + console.error("Error handling generate click:", error); + } + } + + /** + * Handles the stop generation event from the AI toolbar. + * Fires the stop-generation event to notify listeners. + * + * @private + */ + handleStopGeneration = () => { + try { + this.fireDecoratorEvent("stop-generation"); + } catch (error) { + // eslint-disable-next-line no-console + console.error("Error handling stop generation:", error); + } + } +} + +AITextArea.define(); + +export default AITextArea; diff --git a/packages/ai/src/AITextAreaTemplate.tsx b/packages/ai/src/AITextAreaTemplate.tsx new file mode 100644 index 000000000000..98c74b6317e0 --- /dev/null +++ b/packages/ai/src/AITextAreaTemplate.tsx @@ -0,0 +1,88 @@ +import type AITextArea from "./AITextArea.js"; +import AiWritingAssistantToolbar from "./AiWritingAssistantToolbar.js"; +import BusyIndicator from "@ui5/webcomponents/dist/BusyIndicator.js"; +import TextAreaPopoverTemplate from "@ui5/webcomponents/dist/TextAreaPopoverTemplate.js"; + +export default function AITextAreaTemplate(this: AITextArea) { + const isBusy = this.assistantState === "Loading"; + + return ( +
+
+
+ {this.growing && + + } + + + +
+ + + +
+
+ + {this.showExceededText && + {this._exceededTextProps.exceededText} + } + + {this.hasValueState && + {this.ariaValueStateHiddenText} + } +
+ + {TextAreaPopoverTemplate.call(this)} + +
+ +
+
+ ); +} diff --git a/packages/ai/src/AiWritingAssistantTemplate.tsx b/packages/ai/src/AiWritingAssistantTemplate.tsx new file mode 100644 index 000000000000..7779e81de8d9 --- /dev/null +++ b/packages/ai/src/AiWritingAssistantTemplate.tsx @@ -0,0 +1,25 @@ +import type AiWritingAssistant from "./AITextArea.js"; +import AiWritingAssistantToolbar from "./AiWritingAssistantToolbar.js"; + +export default function AiWritingAssistantTemplate(this: AiWritingAssistant) { + return ( +
+
+ +
+ +
+ +
+
+ ); +} diff --git a/packages/ai/src/AiWritingAssistantToolbar.ts b/packages/ai/src/AiWritingAssistantToolbar.ts new file mode 100644 index 000000000000..d7559456ad64 --- /dev/null +++ b/packages/ai/src/AiWritingAssistantToolbar.ts @@ -0,0 +1,173 @@ +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 UI5Element from "@ui5/webcomponents-base"; +import announce from "@ui5/webcomponents-base/dist/util/InvisibleMessage.js"; + +// Styles +import AiWritingAssistantCss from "./generated/themes/AITextArea.css.js"; + +// Templates +import AiWritingAssistantToolbarTemplate from "./AiWritingAssistantToolbarTemplate.js"; +import Version from "./Version.js"; +import type AssistantState from "./types/AssistantState.js"; + +// Icons +import "@ui5/webcomponents-icons/dist/ai.js"; +import "@ui5/webcomponents-icons/dist/stop.js"; + +/** + * @class + * + * ### Overview + * + * The `ui5-writing-assistant-toolbar` component provides a specialized toolbar for AI Writing Assistant functionality. + * It manages different states of the AI assistant and provides version navigation capabilities. + * + * ### Structure + * The `ui5-writing-assistant-toolbar` consists of the following elements: + * - AI Generate Button: Triggers AI text generation or stops ongoing generation + * - Version Navigation: Allows navigation between multiple AI-generated results + * - Action Label: Displays the current AI action being performed + * + * ### ES6 Module Import + * + * `import "@sap-webcomponents/rich-text-editor/dist/AiWritingAssistantToolbar.js";` + * + * @constructor + * @extends UI5Element + * @since 1.0.0-rc.1 + * @private + */ +@customElement({ + tag: "ui5-writing-assistant-toolbar", + languageAware: true, + renderer: jsxRenderer, + template: AiWritingAssistantToolbarTemplate, + styles: [AiWritingAssistantCss], + dependencies: [ + Version, + ], +}) + +/** + * Fired when the user clicks on the "Previous Version" button. + * + * @public + */ +@event("previous-version-click") + +/** + * Fired when the user clicks on the "Next Version" button. + * + * @public + */ +@event("next-version-click") + +/** + * Fired when the user clicks on the "Generate" button to start AI text generation. + * + * @public + */ +@event("generate-click") + +/** + * Fired when the user clicks on the "Stop" button to stop ongoing AI text generation. + * + * @public + */ +@event("stop-generation") + +class AiWritingAssistantToolbar extends UI5Element { + eventDetails!: { + "previous-version-click": object; + "next-version-click": object; + "generate-click": { clickTarget?: HTMLElement }; + "stop-generation": object; + }; + + /** + * Defines the current state of the AI Writing Assistant. + * + * Available values are: + * - `"Default"`: Shows only the main toolbar button. + * - `"Loading"`: Indicates that an action is in progress. + * - `"SingleResult"`: A single result is displayed. + * - `"MultipleResults"`: Multiple results are displayed. + * + * @default "Default" + * @public + */ + @property() + assistantState: `${AssistantState}` = "Default"; + /** + * Defines the action text of the `sap-writing-asstistant-editor`. + * + * @default "" + * @public + */ + @property() + actionText = ""; + + /** + * Indicates the index of the currently displayed result version. + * + * The index is **1-based** (i.e. `1` represents the first result). + * + * @default 1 + * @public + */ + @property({ type: Number }) + currentVersionIndex = 1; + + /** + * Indicates the total number of result versions available. + * + * When not set or `0`, versioning UI will be hidden. + * + * @default 0 + * @public + */ + @property({ type: Number }) + totalVersions = 0; + + /** + * Handles the click event for the "Previous Version" button. + * + * @public + */ + handlePreviousVersionClick(): void { + this.fireDecoratorEvent("previous-version-click"); + } + + /** + * Handles the click event for the "Next Version" button. + * + * @public + */ + handleNextVersionClick(): void { + this.fireDecoratorEvent("next-version-click"); + } + + /** + * Handles the click event for the AI generate button. + * Toggles between generate and stop states based on current button state. + * + * @private + */ + _handleGenerateClick(e: Event) { + const target = e.target as HTMLElement & { state?: string }; + if (target?.state === "generating") { + // If the button is in generating state, stop the generation + this.fireDecoratorEvent("stop-generation"); + } else { + this.fireDecoratorEvent("generate-click", { clickTarget: target }); + announce("AI writing assistant generating. Stop generating (ESC)", "Polite"); + } + } +} + +AiWritingAssistantToolbar.define(); + +export default AiWritingAssistantToolbar; diff --git a/packages/ai/src/AiWritingAssistantToolbarTemplate.tsx b/packages/ai/src/AiWritingAssistantToolbarTemplate.tsx new file mode 100644 index 000000000000..03b658121bbd --- /dev/null +++ b/packages/ai/src/AiWritingAssistantToolbarTemplate.tsx @@ -0,0 +1,50 @@ +import Version from "./Version.js"; +import type AiWritingAssistantToolbar from "./AiWritingAssistantToolbar.js"; + +import Toolbar from "@ui5/webcomponents/dist/Toolbar.js"; +import ToolbarSpacer from "@ui5/webcomponents/dist/ToolbarSpacer.js"; +import Label from "@ui5/webcomponents/dist/Label.js"; +import Button from "@ui5/webcomponents/dist/Button.js"; + +export default function AiWritingAssistantToolbarTemplate(this: AiWritingAssistantToolbar) { + const isMultiResults = this.assistantState === "MultipleResults"; + const isBusy = this.assistantState === "Loading"; + const isDefault = this.assistantState === "Default"; + + return ( + + {isMultiResults && ( + + )} + + {!isDefault && ( + + )} + + + + + + + + } + ; +} diff --git a/packages/ai/src/bundle.esm.ts b/packages/ai/src/bundle.esm.ts index e66e9c14a4d1..42f83ecb2818 100644 --- a/packages/ai/src/bundle.esm.ts +++ b/packages/ai/src/bundle.esm.ts @@ -7,5 +7,6 @@ import "./Assets.js"; import Button from "./Button.js"; import ButtonState from "./ButtonState.js"; import PromptInput from "./PromptInput.js"; +import AITextArea from "./AITextArea.js"; export default testAssets; diff --git a/packages/ai/src/themes/AITextArea.css b/packages/ai/src/themes/AITextArea.css new file mode 100644 index 000000000000..e92375037166 --- /dev/null +++ b/packages/ai/src/themes/AITextArea.css @@ -0,0 +1,52 @@ +/* AI TextArea Root Container */ +.ui5-ai-textarea-root { + position: relative; + display: flex; + flex-direction: column; + width: 100%; + height: 100%; +} + +/* Textarea wrapper modifications for AI component */ +.ui5-ai-textarea-root .ui5-textarea-wrapper { + display: flex; + flex-direction: column; + flex: 1; + position: relative; +} + +/* Footer container for the toolbar */ +.ui5-ai-textarea-root [part="footer"] { + position: relative; + width: 100%; + margin-top: auto; + flex-shrink: 0; +} + +/* AI Writing Assistant Toolbar */ +.ui5-ai-writing-assistant-footer-bar { + background: var(--_ui5_texteditor_toolbar_background, var(--sapPageFooter_Background)); + width: 100%; + box-sizing: border-box; + box-shadow: none; + border-bottom: none; + position: relative; + margin: 0; + padding: 0; +} + +/* Ensure toolbar appears at the bottom with proper border */ +.ui5-ai-writing-assistant-footer-bar { + border-top: 1px solid var(--sapPageFooter_BorderColor); +} + +/* Hide border when in default state */ +:host([assistant-state="Default"]) .ui5-ai-writing-assistant-footer-bar { + border-top: none; +} + +/* Menu wrapper positioning */ +#ai-menu-wrapper { + position: relative; + z-index: 1000; +} diff --git a/packages/ai/src/themes/Version.css b/packages/ai/src/themes/Version.css new file mode 100644 index 000000000000..7c8bae079863 --- /dev/null +++ b/packages/ai/src/themes/Version.css @@ -0,0 +1,20 @@ +/* ui5-icon { + color: var(--sapButton_IconColor); +} + +ui5-icon[name="ai"]:hover { + background: var(--sapButton_Hover_Background); + border-left: 1px solid var(--sapButton_BorderColor); +} */ + +ui5-label { + margin: 0 0.5rem; +} + +/* ui5-icon[name="ai"].icon-pressed:not(:hover) { + background: var(--sapField_Hover_Background); + box-shadow: var(--sapField_Hover_Shadow); + color: var(--sapButton_Selected_TextColor); + box-sizing: border-box; + border-radius: var(--sapField_BorderCornerRadius); +} */ \ No newline at end of file diff --git a/packages/ai/src/types/AssistantState.ts b/packages/ai/src/types/AssistantState.ts new file mode 100644 index 000000000000..f642cb4d5356 --- /dev/null +++ b/packages/ai/src/types/AssistantState.ts @@ -0,0 +1,39 @@ +/** + * Defines the UI states for the AI Writing Assistant. + * + * These states control the visual and behavioral rendering of the assistant component. + * + * @public + * @experimental + */ +enum AssistantState { + /** + * The default state. + * Shows only the initial toolbar button. + * @public + */ + Default = "Default", + + /** + * The loading state. + * Indicates an action (e.g., generation) is in progress. + * @public + */ + Loading = "Loading", + + /** + * The result state. + * Displayed after a successful AI response is generated. + * @public + */ + SingleResult = "SingleResult", + + /** + * The result state. + * Displayed after a successful AI response is generated. + * @public + */ + MultipleResults = "MultipleResults", +} + +export default AssistantState; diff --git a/packages/ai/test/pages/AITextArea.html b/packages/ai/test/pages/AITextArea.html new file mode 100644 index 000000000000..72198591d00c --- /dev/null +++ b/packages/ai/test/pages/AITextArea.html @@ -0,0 +1,629 @@ + + + + + + + + + AI Writing Assistant - Best Practice Example + + + + + + + + +

AI Writing Assistant Demo

+ + + + + + + + + + + + + diff --git a/packages/main/src/TextAreaTemplate.tsx b/packages/main/src/TextAreaTemplate.tsx index 728a4b6c3e2a..b39548f78ff8 100644 --- a/packages/main/src/TextAreaTemplate.tsx +++ b/packages/main/src/TextAreaTemplate.tsx @@ -1,7 +1,12 @@ import type TextArea from "./TextArea.js"; +import BusyIndicator from "./BusyIndicator.js"; import TextAreaPopoverTemplate from "./TextAreaPopoverTemplate.js"; +import type { JsxTemplate } from "@ui5/webcomponents-base/dist/index.js"; -export default function TextAreaTemplate(this: TextArea) { +export default function TextAreaTemplate(this: TextArea, hooks?: { + footer?: JsxTemplate, + busy?: boolean, +}) { return ( <>
} - + + + +
+ + {(hooks?.footer || defaultFooter).call(this)} + +
{ afterTextarea.call(this) } @@ -61,4 +77,5 @@ export default function TextAreaTemplate(this: TextArea) { ); } -function afterTextarea(this: TextArea) {} +export function afterTextarea(this: TextArea) {} +export function defaultFooter(this: TextArea) {} From d18dcac9934fa4351144732538f788511cac1e02 Mon Sep 17 00:00:00 2001 From: Deshev Date: Mon, 1 Sep 2025 09:57:04 +0300 Subject: [PATCH 02/41] feat(ui5-ai-textarea): introduce new component initial poc draft, all naming and logic are work in progress and subject to change BGSOFUIRILA-4032 --- packages/ai/cypress/specs/AITextArea.cy.d.ts | 1 + packages/ai/cypress/specs/AITextArea.cy.js | 368 ++++++++++++ packages/ai/cypress/specs/AITextArea.cy.tsx | 552 ++++++++++++++++++ packages/ai/src/AITextArea.ts | 121 +++- packages/ai/src/AITextAreaTemplate.tsx | 2 +- packages/ai/src/AiWritingAssistantToolbar.ts | 2 +- .../src/AiWritingAssistantToolbarTemplate.tsx | 2 +- packages/ai/src/Version.ts | 90 --- packages/ai/src/Versioning.ts | 181 ++++++ packages/ai/src/VersioningTemplate.tsx | 31 + packages/ai/src/themes/Versioning.css | 7 + packages/ai/src/types/AssistantState.ts | 7 + packages/ai/test/pages/AITextArea.html | 6 +- packages/localization/used-modules.txt | 2 +- 14 files changed, 1264 insertions(+), 108 deletions(-) create mode 100644 packages/ai/cypress/specs/AITextArea.cy.d.ts create mode 100644 packages/ai/cypress/specs/AITextArea.cy.js create mode 100644 packages/ai/cypress/specs/AITextArea.cy.tsx delete mode 100644 packages/ai/src/Version.ts create mode 100644 packages/ai/src/Versioning.ts create mode 100644 packages/ai/src/VersioningTemplate.tsx create mode 100644 packages/ai/src/themes/Versioning.css diff --git a/packages/ai/cypress/specs/AITextArea.cy.d.ts b/packages/ai/cypress/specs/AITextArea.cy.d.ts new file mode 100644 index 000000000000..cb0ff5c3b541 --- /dev/null +++ b/packages/ai/cypress/specs/AITextArea.cy.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/ai/cypress/specs/AITextArea.cy.js b/packages/ai/cypress/specs/AITextArea.cy.js new file mode 100644 index 000000000000..2fea5ac8b457 --- /dev/null +++ b/packages/ai/cypress/specs/AITextArea.cy.js @@ -0,0 +1,368 @@ +import { jsx as _jsx } from "@ui5/webcomponents-base/jsx-runtime"; +import AITextArea from "../../src/AITextArea.js"; +import Menu from "@ui5/webcomponents/dist/Menu.js"; +import MenuItem from "@ui5/webcomponents/dist/MenuItem.js"; +describe("Initialization", () => { + it("should render with Initial properties", () => { + cy.mount(_jsx(AITextArea, {})); + cy.get("[ui5-ai-textarea]") + .as("textarea") + .should("exist") + .should("have.prop", "assistantState", "Initial") + .should("have.prop", "actionText", "") + .should("have.prop", "currentVersionIndex", 1) + .should("have.prop", "totalVersions", 0); + cy.get("@textarea") + .shadow() + .find("[sap-ai-rich-text-editor-toolbar]") + .as("toolbar") + .should("exist"); + }); + it("should set initial value as a property", () => { + cy.mount(_jsx(AITextArea, { value: "AI initial value" })); + cy.get("[ui5-ai-textarea]") + .as("textarea") + .should("have.prop", "value", "AI initial value"); + }); +}); +describe("Assistant States", () => { + it("should display Initial state correctly", () => { + cy.mount(_jsx(AITextArea, { assistantState: "Initial" })); + cy.get("[ui5-ai-textarea]") + .as("textarea") + .shadow() + .find("[sap-ai-rich-text-editor-toolbar]") + .as("toolbar") + .should("have.prop", "assistantState", "Initial"); + }); + it("should display Loading state correctly", () => { + cy.mount(_jsx(AITextArea, { assistantState: "Loading", actionText: "Generating content..." })); + cy.get("[ui5-ai-textarea]") + .as("textarea") + .shadow() + .find("[sap-ai-rich-text-editor-toolbar]") + .as("toolbar") + .should("have.prop", "assistantState", "Loading") + .should("have.prop", "actionText", "Generating content..."); + }); + it("should display SingleResult state correctly", () => { + cy.mount(_jsx(AITextArea, { assistantState: "SingleResult", actionText: "Generated text", currentVersionIndex: 1, totalVersions: 1 })); + cy.get("[ui5-ai-textarea]") + .as("textarea") + .shadow() + .find("[sap-ai-rich-text-editor-toolbar]") + .as("toolbar") + .should("have.prop", "assistantState", "SingleResult") + .should("have.prop", "actionText", "Generated text") + .should("have.prop", "currentVersionIndex", 1) + .should("have.prop", "totalVersions", 1); + }); + it("should display MultipleResults state correctly", () => { + cy.mount(_jsx(AITextArea, { assistantState: "MultipleResults", actionText: "Generated text", currentVersionIndex: 2, totalVersions: 3 })); + cy.get("[ui5-ai-textarea]") + .as("textarea") + .shadow() + .find("[sap-ai-rich-text-editor-toolbar]") + .as("toolbar") + .should("have.prop", "assistantState", "MultipleResults") + .should("have.prop", "actionText", "Generated text") + .should("have.prop", "currentVersionIndex", 2) + .should("have.prop", "totalVersions", 3); + }); +}); +describe("Version Navigation", () => { + it("should fire previous-version-click event with proper event details", () => { + let eventDetail = null; + cy.mount(_jsx(AITextArea, { assistantState: "MultipleResults", currentVersionIndex: 2, totalVersions: 3, onPreviousVersionClick: (e) => { eventDetail = e.detail; } })); + cy.get("[ui5-ai-textarea]") + .as("textarea") + .shadow() + .find("[sap-ai-rich-text-editor-toolbar]") + .as("toolbar") + .shadow() + .find("[sap-writing-assistant-versioning]") + .as("versioning"); + cy.get("@versioning") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .should("not.be.disabled") + .realClick(); + cy.wrap(null).should(() => { + expect(eventDetail).to.not.be.null; + expect(eventDetail.currentIndex).to.eq(2); + expect(eventDetail.totalVersions).to.eq(3); + }); + }); + it("should fire next-version-click event with proper event details", () => { + let eventDetail = null; + cy.mount(_jsx(AITextArea, { assistantState: "MultipleResults", currentVersionIndex: 1, totalVersions: 3, onNextVersionClick: (e) => { eventDetail = e.detail; } })); + cy.get("[ui5-ai-textarea]") + .as("textarea") + .shadow() + .find("[sap-ai-rich-text-editor-toolbar]") + .as("toolbar") + .shadow() + .find("[sap-writing-assistant-versioning]") + .as("versioning"); + cy.get("@versioning") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .should("not.be.disabled") + .realClick(); + cy.wrap(null).should(() => { + expect(eventDetail).to.not.be.null; + expect(eventDetail.currentIndex).to.eq(1); + expect(eventDetail.totalVersions).to.eq(3); + }); + }); + it("should disable previous button when at first version", () => { + cy.mount(_jsx(AITextArea, { assistantState: "MultipleResults", currentVersionIndex: 1, totalVersions: 3 })); + cy.get("[ui5-ai-textarea]") + .shadow() + .find("[sap-ai-rich-text-editor-toolbar]") + .shadow() + .find("[sap-writing-assistant-versioning]") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .should("be.disabled"); + }); + it("should disable next button when at last version", () => { + cy.mount(_jsx(AITextArea, { assistantState: "MultipleResults", currentVersionIndex: 3, totalVersions: 3 })); + cy.get("[ui5-ai-textarea]") + .shadow() + .find("[sap-ai-rich-text-editor-toolbar]") + .shadow() + .find("[sap-writing-assistant-versioning]") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .should("be.disabled"); + }); + it("should sync textarea content after version navigation", () => { + const initialValue = "Version 1 content"; + const newValue = "Version 2 content"; + cy.mount(_jsx(AITextArea, { value: initialValue, assistantState: "MultipleResults", currentVersionIndex: 1, totalVersions: 2 })); + cy.get("[ui5-ai-textarea]") + .as("textarea") + .then(($el) => { + $el[0].value = newValue; + }); + cy.get("@textarea") + .shadow() + .find("[sap-ai-rich-text-editor-toolbar]") + .as("toolbar") + .shadow() + .find("[sap-writing-assistant-versioning]") + .as("versioning") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .realClick(); + cy.wait(100); + cy.get("@textarea") + .shadow() + .find("textarea") + .should("have.value", newValue); + }); +}); +describe("Menu Integration", () => { + it("should handle menu slot correctly", () => { + cy.mount(_jsx(AITextArea, { children: _jsx(Menu, { slot: "menu", id: "test-menu", children: _jsx(MenuItem, { text: "Generate text" }) }) })); + cy.get("[ui5-ai-textarea]") + .as("textarea") + .find("ui5-menu[slot='menu']") + .should("exist"); + }); + it("should open menu when generate button is clicked", () => { + let menuOpened = false; + cy.mount(_jsx(AITextArea, { children: _jsx(Menu, { slot: "menu", id: "test-menu", onOpen: () => { menuOpened = true; }, children: _jsx(MenuItem, { text: "Generate text" }) }) })); + cy.get("[ui5-ai-textarea]") + .as("textarea") + .shadow() + .find("[sap-ai-rich-text-editor-toolbar]") + .as("toolbar") + .shadow() + .find("#ai-menu-btn") + .as("menuButton") + .realClick(); + cy.wrap(null).should(() => { + expect(menuOpened).to.be.true; + }); + }); +}); +describe("Stop Generation", () => { + it("should fire stop-generation event", () => { + let stopEventFired = false; + cy.mount(_jsx(AITextArea, { assistantState: "Loading", onStopGeneration: () => { stopEventFired = true; } })); + cy.get("[ui5-ai-textarea]") + .as("textarea") + .shadow() + .find("[sap-ai-rich-text-editor-toolbar]") + .as("toolbar") + .shadow() + .find("#ai-menu-btn") + .as("menuButton") + .realClick(); + cy.wrap(null).should(() => { + expect(stopEventFired).to.be.true; + }); + }); +}); +describe("Keyboard Shortcuts", () => { + it("should handle Shift+F4 to focus AI button", () => { + cy.mount(_jsx(AITextArea, {})); + cy.get("[ui5-ai-textarea]") + .as("textarea") + .shadow() + .find("textarea") + .focus() + .realPress(['Shift', 'F4']); + cy.get("@textarea") + .shadow() + .find("[sap-ai-rich-text-editor-toolbar]") + .as("toolbar") + .shadow() + .find("#ai-menu-btn") + .as("menuButton") + .should("be.focused"); + }); + it("should handle Ctrl+Shift+Z for previous version in MultipleResults state", () => { + let previousVersionClicked = false; + cy.mount(_jsx(AITextArea, { assistantState: "MultipleResults", currentVersionIndex: 2, totalVersions: 3, onPreviousVersionClick: () => { previousVersionClicked = true; } })); + cy.get("[ui5-ai-textarea]") + .as("textarea") + .shadow() + .find("textarea") + .focus() + .realPress(['Control', 'Shift', 'z']); + cy.wrap(null).should(() => { + expect(previousVersionClicked).to.be.true; + }); + }); + it("should handle Ctrl+Shift+Y for next version in MultipleResults state", () => { + let nextVersionClicked = false; + cy.mount(_jsx(AITextArea, { assistantState: "MultipleResults", currentVersionIndex: 1, totalVersions: 3, onNextVersionClick: () => { nextVersionClicked = true; } })); + cy.get("[ui5-ai-textarea]") + .as("textarea") + .shadow() + .find("textarea") + .focus() + .realPress(['Control', 'Shift', 'y']); + cy.wrap(null).should(() => { + expect(nextVersionClicked).to.be.true; + }); + }); +}); +describe("TextArea Integration", () => { + it("should inherit TextArea functionality", () => { + cy.mount(_jsx(AITextArea, { value: "Test content" })); + cy.get("[ui5-ai-textarea]") + .as("textarea") + .shadow() + .find("textarea") + .should("have.value", "Test content") + .type(" additional text"); + cy.get("@textarea") + .should("have.prop", "value") + .and("include", "Test content") + .and("include", "additional text"); + }); + it("should support readonly mode", () => { + cy.mount(_jsx(AITextArea, { value: "Readonly content", readonly: true })); + cy.get("[ui5-ai-textarea]") + .as("textarea") + .should("have.attr", "readonly"); + cy.get("@textarea") + .shadow() + .find("textarea") + .should("have.attr", "readonly") + .should("have.value", "Readonly content"); + }); + it("should support disabled mode", () => { + cy.mount(_jsx(AITextArea, { value: "Disabled content", disabled: true })); + cy.get("[ui5-ai-textarea]") + .as("textarea") + .should("have.attr", "disabled"); + cy.get("@textarea") + .shadow() + .find("textarea") + .should("have.attr", "disabled") + .should("have.value", "Disabled content"); + }); +}); +describe("Event Handling", () => { + it("should handle input events", () => { + let inputCount = 0; + cy.mount(_jsx(AITextArea, { onInput: () => { inputCount++; } })); + cy.get("[ui5-ai-textarea]") + .shadow() + .find("textarea") + .type("Hello"); + cy.wrap(null).should(() => { + expect(inputCount).to.eq(5); + }); + }); + it("should handle change events", () => { + let changeEventDetail = null; + cy.mount(_jsx(AITextArea, { onChange: (e) => { changeEventDetail = e.detail; } })); + cy.get("[ui5-ai-textarea]") + .shadow() + .find("textarea") + .type("test") + .blur(); + cy.wrap(null).should(() => { + expect(changeEventDetail?.value).to.include("test"); + }); + }); +}); +describe("Busy State", () => { + it("should show busy indicator when in Loading state", () => { + cy.mount(_jsx(AITextArea, { assistantState: "Loading" })); + cy.get("[ui5-ai-textarea]") + .shadow() + .find("ui5-busy-indicator") + .should("have.attr", "active"); + }); + it("should hide busy indicator when not in Loading state", () => { + cy.mount(_jsx(AITextArea, { assistantState: "Initial" })); + cy.get("[ui5-ai-textarea]") + .shadow() + .find("ui5-busy-indicator") + .should("not.have.attr", "active"); + }); +}); +describe("Accessibility", () => { + it("should have proper ARIA attributes", () => { + cy.mount(_jsx(AITextArea, { ariaLabel: "AI-powered textarea" })); + cy.get("[ui5-ai-textarea]") + .shadow() + .find("textarea") + .should("have.attr", "aria-label", "AI-powered textarea"); + }); + it("should support required attribute", () => { + cy.mount(_jsx(AITextArea, { required: true })); + cy.get("[ui5-ai-textarea]") + .shadow() + .find("textarea") + .should("have.attr", "aria-required", "true"); + }); +}); +describe("Error Handling", () => { + it("should handle invalid assistant state gracefully", () => { + cy.mount(_jsx(AITextArea, { assistantState: "InvalidState" })); + cy.get("[ui5-ai-textarea]") + .should("exist"); + cy.get("[ui5-ai-textarea]") + .shadow() + .find("[sap-ai-rich-text-editor-toolbar]") + .should("exist"); + }); + it("should handle invalid version indices gracefully", () => { + cy.mount(_jsx(AITextArea, { assistantState: "MultipleResults", currentVersionIndex: -1, totalVersions: 3 })); + cy.get("[ui5-ai-textarea]") + .should("exist"); + cy.get("[ui5-ai-textarea]") + .shadow() + .find("[sap-ai-rich-text-editor-toolbar]") + .should("exist"); + }); +}); +//# sourceMappingURL=AITextArea.cy.js.map \ No newline at end of file diff --git a/packages/ai/cypress/specs/AITextArea.cy.tsx b/packages/ai/cypress/specs/AITextArea.cy.tsx new file mode 100644 index 000000000000..f5e349580a67 --- /dev/null +++ b/packages/ai/cypress/specs/AITextArea.cy.tsx @@ -0,0 +1,552 @@ +import AITextArea from "../../src/AITextArea.js"; +import Menu from "@ui5/webcomponents/dist/Menu.js"; +import MenuItem from "@ui5/webcomponents/dist/MenuItem.js"; + +describe("Initialization", () => { + it("should render with Initial properties", () => { + cy.mount(); + + cy.get("[ui5-ai-textarea]") + .as("textarea") + .should("exist") + .should("have.prop", "assistantState", "Initial") + .should("have.prop", "actionText", "") + .should("have.prop", "currentVersionIndex", 1) + .should("have.prop", "totalVersions", 0); + + cy.get("@textarea") + .shadow() + .find("[sap-ai-rich-text-editor-toolbar]") + .as("toolbar") + .should("exist"); + }); + + it("should set initial value as a property", () => { + cy.mount(); + + cy.get("[ui5-ai-textarea]") + .as("textarea") + .should("have.prop", "value", "AI initial value"); + }); +}); + +describe("Assistant States", () => { + it("should display Initial state correctly", () => { + cy.mount(); + + cy.get("[ui5-ai-textarea]") + .as("textarea") + .shadow() + .find("[sap-ai-rich-text-editor-toolbar]") + .as("toolbar") + .should("have.prop", "assistantState", "Initial"); + }); + + it("should display Loading state correctly", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-textarea]") + .as("textarea") + .shadow() + .find("[sap-ai-rich-text-editor-toolbar]") + .as("toolbar") + .should("have.prop", "assistantState", "Loading") + .should("have.prop", "actionText", "Generating content..."); + }); + + it("should display SingleResult state correctly", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-textarea]") + .as("textarea") + .shadow() + .find("[sap-ai-rich-text-editor-toolbar]") + .as("toolbar") + .should("have.prop", "assistantState", "SingleResult") + .should("have.prop", "actionText", "Generated text") + .should("have.prop", "currentVersionIndex", 1) + .should("have.prop", "totalVersions", 1); + }); + + it("should display MultipleResults state correctly", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-textarea]") + .as("textarea") + .shadow() + .find("[sap-ai-rich-text-editor-toolbar]") + .as("toolbar") + .should("have.prop", "assistantState", "MultipleResults") + .should("have.prop", "actionText", "Generated text") + .should("have.prop", "currentVersionIndex", 2) + .should("have.prop", "totalVersions", 3); + }); +}); + +describe("Version Navigation", () => { + it("should fire previous-version-click event with proper event details", () => { + let eventDetail: any = null; + + cy.mount( + { eventDetail = e.detail; }} + /> + ); + + cy.get("[ui5-ai-textarea]") + .as("textarea") + .shadow() + .find("[sap-ai-rich-text-editor-toolbar]") + .as("toolbar") + .shadow() + .find("[sap-writing-assistant-versioning]") + .as("versioning"); + + cy.get("@versioning") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .should("not.be.disabled") + .realClick(); + + cy.wrap(null).should(() => { + expect(eventDetail).to.not.be.null; + expect(eventDetail.currentIndex).to.eq(2); + expect(eventDetail.totalVersions).to.eq(3); + }); + }); + + it("should fire next-version-click event with proper event details", () => { + let eventDetail: any = null; + + cy.mount( + { eventDetail = e.detail; }} + /> + ); + + cy.get("[ui5-ai-textarea]") + .as("textarea") + .shadow() + .find("[sap-ai-rich-text-editor-toolbar]") + .as("toolbar") + .shadow() + .find("[sap-writing-assistant-versioning]") + .as("versioning"); + + cy.get("@versioning") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .should("not.be.disabled") + .realClick(); + + cy.wrap(null).should(() => { + expect(eventDetail).to.not.be.null; + expect(eventDetail.currentIndex).to.eq(1); + expect(eventDetail.totalVersions).to.eq(3); + }); + }); + + it("should disable previous button when at first version", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-textarea]") + .shadow() + .find("[sap-ai-rich-text-editor-toolbar]") + .shadow() + .find("[sap-writing-assistant-versioning]") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .should("be.disabled"); + }); + + it("should disable next button when at last version", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-textarea]") + .shadow() + .find("[sap-ai-rich-text-editor-toolbar]") + .shadow() + .find("[sap-writing-assistant-versioning]") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .should("be.disabled"); + }); + + it("should sync textarea content after version navigation", () => { + const initialValue = "Version 1 content"; + const newValue = "Version 2 content"; + + cy.mount( + + ); + + cy.get("[ui5-ai-textarea]") + .as("textarea") + .then(($el) => { + ($el[0] as any).value = newValue; + }); + + cy.get("@textarea") + .shadow() + .find("[sap-ai-rich-text-editor-toolbar]") + .as("toolbar") + .shadow() + .find("[sap-writing-assistant-versioning]") + .as("versioning") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .realClick(); + + cy.wait(100); + + cy.get("@textarea") + .shadow() + .find("textarea") + .should("have.value", newValue); + }); +}); + +describe("Menu Integration", () => { + it("should handle menu slot correctly", () => { + cy.mount( + + + + + + ); + + cy.get("[ui5-ai-textarea]") + .as("textarea") + .find("ui5-menu[slot='menu']") + .should("exist"); + }); + + it("should open menu when generate button is clicked", () => { + let menuOpened = false; + + cy.mount( + + { menuOpened = true; }} + > + + + + ); + + cy.get("[ui5-ai-textarea]") + .as("textarea") + .shadow() + .find("[sap-ai-rich-text-editor-toolbar]") + .as("toolbar") + .shadow() + .find("#ai-menu-btn") + .as("menuButton") + .realClick(); + + cy.wrap(null).should(() => { + expect(menuOpened).to.be.true; + }); + }); +}); + +describe("Stop Generation", () => { + it("should fire stop-generation event", () => { + let stopEventFired = false; + + cy.mount( + { stopEventFired = true; }} + /> + ); + + cy.get("[ui5-ai-textarea]") + .as("textarea") + .shadow() + .find("[sap-ai-rich-text-editor-toolbar]") + .as("toolbar") + .shadow() + .find("#ai-menu-btn") + .as("menuButton") + .realClick(); + + cy.wrap(null).should(() => { + expect(stopEventFired).to.be.true; + }); + }); +}); + +describe("Keyboard Shortcuts", () => { + it("should handle Shift+F4 to focus AI button", () => { + cy.mount(); + + cy.get("[ui5-ai-textarea]") + .as("textarea") + .shadow() + .find("textarea") + .focus() + .realPress(['Shift', 'F4']); + + cy.get("@textarea") + .shadow() + .find("[sap-ai-rich-text-editor-toolbar]") + .as("toolbar") + .shadow() + .find("#ai-menu-btn") + .as("menuButton") + .should("be.focused"); + }); + + it("should handle Ctrl+Shift+Z for previous version in MultipleResults state", () => { + let previousVersionClicked = false; + + cy.mount( + { previousVersionClicked = true; }} + /> + ); + + cy.get("[ui5-ai-textarea]") + .as("textarea") + .shadow() + .find("textarea") + .focus() + .realPress(['Control', 'Shift', 'z']); + + cy.wrap(null).should(() => { + expect(previousVersionClicked).to.be.true; + }); + }); + + it("should handle Ctrl+Shift+Y for next version in MultipleResults state", () => { + let nextVersionClicked = false; + + cy.mount( + { nextVersionClicked = true; }} + /> + ); + + cy.get("[ui5-ai-textarea]") + .as("textarea") + .shadow() + .find("textarea") + .focus() + .realPress(['Control', 'Shift', 'y']); + + cy.wrap(null).should(() => { + expect(nextVersionClicked).to.be.true; + }); + }); +}); + +describe("TextArea Integration", () => { + it("should inherit TextArea functionality", () => { + cy.mount(); + + cy.get("[ui5-ai-textarea]") + .as("textarea") + .shadow() + .find("textarea") + .should("have.value", "Test content") + .type(" additional text"); + + cy.get("@textarea") + .should("have.prop", "value") + .and("include", "Test content") + .and("include", "additional text"); + }); + + it("should support readonly mode", () => { + cy.mount(); + + cy.get("[ui5-ai-textarea]") + .as("textarea") + .should("have.attr", "readonly"); + + cy.get("@textarea") + .shadow() + .find("textarea") + .should("have.attr", "readonly") + .should("have.value", "Readonly content"); + }); + + it("should support disabled mode", () => { + cy.mount(); + + cy.get("[ui5-ai-textarea]") + .as("textarea") + .should("have.attr", "disabled"); + + cy.get("@textarea") + .shadow() + .find("textarea") + .should("have.attr", "disabled") + .should("have.value", "Disabled content"); + }); +}); + +describe("Event Handling", () => { + it("should handle input events", () => { + let inputCount = 0; + + cy.mount( + { inputCount++; }} + /> + ); + + cy.get("[ui5-ai-textarea]") + .shadow() + .find("textarea") + .type("Hello"); + + cy.wrap(null).should(() => { + expect(inputCount).to.eq(5); + }); + }); + + it("should handle change events", () => { + let changeEventDetail: any = null; + + cy.mount( + { changeEventDetail = e.detail; }} + /> + ); + + cy.get("[ui5-ai-textarea]") + .shadow() + .find("textarea") + .type("test") + .blur(); + + cy.wrap(null).should(() => { + expect(changeEventDetail?.value).to.include("test"); + }); + }); +}); + +describe("Busy State", () => { + it("should show busy indicator when in Loading state", () => { + cy.mount(); + + cy.get("[ui5-ai-textarea]") + .shadow() + .find("ui5-busy-indicator") + .should("have.attr", "active"); + }); + + it("should hide busy indicator when not in Loading state", () => { + cy.mount(); + + cy.get("[ui5-ai-textarea]") + .shadow() + .find("ui5-busy-indicator") + .should("not.have.attr", "active"); + }); +}); + +describe("Accessibility", () => { + it("should have proper ARIA attributes", () => { + cy.mount(); + + cy.get("[ui5-ai-textarea]") + .shadow() + .find("textarea") + .should("have.attr", "aria-label", "AI-powered textarea"); + }); + + it("should support required attribute", () => { + cy.mount(); + + cy.get("[ui5-ai-textarea]") + .shadow() + .find("textarea") + .should("have.attr", "aria-required", "true"); + }); +}); + +describe("Error Handling", () => { + it("should handle invalid assistant state gracefully", () => { + cy.mount(); + + cy.get("[ui5-ai-textarea]") + .should("exist"); + + cy.get("[ui5-ai-textarea]") + .shadow() + .find("[sap-ai-rich-text-editor-toolbar]") + .should("exist"); + }); + + it("should handle invalid version indices gracefully", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-textarea]") + .should("exist"); + + cy.get("[ui5-ai-textarea]") + .shadow() + .find("[sap-ai-rich-text-editor-toolbar]") + .should("exist"); + }); +}); diff --git a/packages/ai/src/AITextArea.ts b/packages/ai/src/AITextArea.ts index 636c32c03d4e..006771ab6cb0 100644 --- a/packages/ai/src/AITextArea.ts +++ b/packages/ai/src/AITextArea.ts @@ -17,7 +17,19 @@ import valueStateMessageStyles from "@ui5/webcomponents/dist/generated/themes/Va // Templates import AITextAreaTemplate from "./AITextAreaTemplate.js"; import AiWritingAssistantToolbar from "./AiWritingAssistantToolbar.js"; -import Version from "./Version.js"; +import Versioning from "./Versioning.js"; + +type VersionClickEventDetail = { + /** + * The current version index (1-based). + */ + currentIndex: number; + + /** + * The total number of versions available. + */ + totalVersions: number; +} /** * @class @@ -36,7 +48,7 @@ import Version from "./Version.js"; * * ### States * The `ui5-ai-textarea` supports multiple states: - * - Default: Shows only the AI button + * - Initial: Shows only the AI button * - Loading: Indicates AI generation in progress * - SingleResult: Shows result with action label * - MultipleResults: Shows result with version navigation @@ -64,7 +76,7 @@ import Version from "./Version.js"; ], dependencies: [ AiWritingAssistantToolbar, - Version, + Versioning, BusyIndicator, ], }) @@ -92,25 +104,25 @@ import Version from "./Version.js"; class AITextArea extends TextArea { eventDetails!: TextArea["eventDetails"] & { - "previous-version-click": object; - "next-version-click": object; - "stop-generation": object; + "previous-version-click": VersionClickEventDetail; + "next-version-click": VersionClickEventDetail; + "stop-generation": null; }; /** * Defines the current state of the AI Writing Assistant. * * Available values are: - * - `"Default"`: Shows only the main toolbar button. + * - `"Initial"`: Shows only the main toolbar button. * - `"Loading"`: Indicates that an action is in progress. * - `"SingleResult"`: A single result is displayed. * - `"MultipleResults"`: Multiple results are displayed. * - * @default "Default" + * @default "Initial" * @public */ @property() - assistantState: `${AssistantState}` = "Default"; + assistantState: `${AssistantState}` = "Initial"; /** * Defines the action text of the AI Writing Assistant. @@ -148,16 +160,103 @@ class AITextArea extends TextArea { /** * Handles the click event for the "Previous Version" button. + * Updates the current version index and syncs content. */ _handlePreviousVersionClick() { - this.fireDecoratorEvent("previous-version-click"); + this.fireDecoratorEvent("previous-version-click", { + currentIndex: this.currentVersionIndex, + totalVersions: this.totalVersions + }); + this._syncContent(); } /** * Handles the click event for the "Next Version" button. + * Updates the current version index and syncs content. */ _handleNextVersionClick() { - this.fireDecoratorEvent("next-version-click"); + this.fireDecoratorEvent("next-version-click", { + currentIndex: this.currentVersionIndex, + totalVersions: this.totalVersions + }); + this._syncContent(); + } + + /** + * Forces the textarea content to sync with the current value. + * @private + */ + _syncContent() { + setTimeout(() => { + const textarea = this.shadowRoot?.querySelector("textarea"); + if (textarea && textarea.value !== this.value) { + textarea.value = this.value; + } + }, 0); + } + + /** + * Handles keydown events for keyboard shortcuts. + * @private + */ + _handleKeydown(keyboardEvent: KeyboardEvent) { + const isCtrlOrCmd = keyboardEvent.ctrlKey || keyboardEvent.metaKey; + const isShift = keyboardEvent.shiftKey; + + if (isShift && keyboardEvent.key.toLowerCase() === "f4") { + const toolbar = this.shadowRoot?.querySelector("sap-ai-rich-text-editor-toolbar") as HTMLElement; + const aiButton = toolbar?.shadowRoot?.querySelector("#ai-menu-btn") as HTMLElement; + + if (aiButton) { + aiButton.focus(); + } + return; + } + + if (this.assistantState === "MultipleResults") { + if (isCtrlOrCmd && isShift && keyboardEvent.key.toLowerCase() === "z") { + keyboardEvent.preventDefault(); + this._handlePreviousVersionClick(); + return; + } + + if (isCtrlOrCmd && isShift && keyboardEvent.key.toLowerCase() === "y") { + keyboardEvent.preventDefault(); + this._handleNextVersionClick(); + } + } + } + + /** + * Opens the AI menu. + * @private + */ + _openMenu() { + const menuNodes = this.getSlottedNodes("menu"); + if (menuNodes.length > 0) { + const menu = menuNodes[0] as HTMLElement & { opener?: HTMLElement; open?: boolean }; + const toolbar = this.shadowRoot?.querySelector("sap-ai-rich-text-editor-toolbar") as HTMLElement; + const aiButton = toolbar?.shadowRoot?.querySelector("#ai-menu-btn") as HTMLElement; + + if (aiButton) { + menu.opener = aiButton; + menu.open = true; + } + } + } + + /** + * Overrides the parent's onAfterRendering to add keydown handler. + * @private + */ + onAfterRendering() { + super.onAfterRendering(); + + // Add keydown event listener to the textarea + const textarea = this.shadowRoot?.querySelector("textarea"); + if (textarea) { + textarea.addEventListener("keydown", this._handleKeydown.bind(this)); + } } /** diff --git a/packages/ai/src/AITextAreaTemplate.tsx b/packages/ai/src/AITextAreaTemplate.tsx index 98c74b6317e0..c8a65bdbba68 100644 --- a/packages/ai/src/AITextAreaTemplate.tsx +++ b/packages/ai/src/AITextAreaTemplate.tsx @@ -53,7 +53,7 @@ export default function AITextAreaTemplate(this: AITextArea) { onScroll={this._onscroll}> -
+