diff --git a/packages/ai/README.md b/packages/ai/README.md index 59b3e7edda55..de2a5c96ed4f 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -15,6 +15,7 @@ Provides web components implementing AI-related visual and interaction. | Button | `ui5-ai-button` | `import "@ui5/webcomponents-ai/dist/Button.js";` | | 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";` | ## Provided assets diff --git a/packages/ai/cypress/specs/TextArea.cy.tsx b/packages/ai/cypress/specs/TextArea.cy.tsx new file mode 100644 index 000000000000..0a8a80c45ad7 --- /dev/null +++ b/packages/ai/cypress/specs/TextArea.cy.tsx @@ -0,0 +1,605 @@ +import AITextArea from "../../src/TextArea.js"; +import Menu from "@ui5/webcomponents/dist/Menu.js"; +import MenuItem from "@ui5/webcomponents/dist/MenuItem.js"; + +describe("Basic", () => { + describe("Initialization", () => { + it("should render with default properties", () => { + cy.mount(); + + cy.get("[ui5-ai-textarea]") + .as("textarea") + .should("exist") + .should("have.prop", "loading", false) + .should("have.prop", "actionText", "") + .should("have.prop", "currentVersionIndex", 1) + .should("have.prop", "totalVersions", 1); + + cy.get("@textarea") + .shadow() + .find("[ui5-ai-writing-assistant]") + .should("exist"); + }); + + it("should set initial value as a property", () => { + cy.mount(); + + cy.get("[ui5-ai-textarea]") + .should("have.prop", "value", "AI initial value"); + }); + }); + + describe("Loading States", () => { + it("should display non-loading state correctly", () => { + cy.mount(); + + cy.get("[ui5-ai-textarea]") + .shadow() + .find("[ui5-ai-writing-assistant]") + .should("have.prop", "loading", false); + }); + + it("should display loading state correctly", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-textarea]") + .shadow() + .find("[ui5-ai-writing-assistant]") + .should("have.prop", "loading", true) + .should("have.prop", "actionText", "Generating content..."); + }); + + it("should display single result correctly", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-textarea]") + .shadow() + .find("[ui5-ai-writing-assistant]") + .should("have.prop", "loading", false) + .should("have.prop", "actionText", "Generated text") + .should("have.prop", "currentVersionIndex", 1) + .should("have.prop", "totalVersions", 1); + }); + + it("should display multiple results correctly", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-textarea]") + .shadow() + .find("[ui5-ai-writing-assistant]") + .should("have.prop", "loading", false) + .should("have.prop", "actionText", "Generated text") + .should("have.prop", "currentVersionIndex", 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-textarea]") + .shadow() + .find("[ui5-ai-writing-assistant]") + .shadow() + .find("[ui5-ai-versioning]") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .should("not.be.disabled") + .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-textarea]") + .shadow() + .find("[ui5-ai-writing-assistant]") + .shadow() + .find("[ui5-ai-versioning]") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .should("not.be.disabled") + .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-textarea]") + .shadow() + .find("[ui5-ai-writing-assistant]") + .shadow() + .find("[ui5-ai-versioning]") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .shadow() + .find("ui5-button") + .should("have.attr", "disabled"); + }); + + it("should disable next button when at last version", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-textarea]") + .shadow() + .find("[ui5-ai-writing-assistant]") + .shadow() + .find("[ui5-ai-versioning]") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .shadow() + .find("ui5-button") + .should("have.attr", "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") + .invoke("prop", "value", newValue); + + cy.get("@textarea") + .shadow() + .find("[ui5-ai-writing-assistant]") + .shadow() + .find("[ui5-ai-versioning]") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .realClick(); + + 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]") + .find("ui5-menu[slot='menu']") + .should("exist"); + }); + + it("should open menu when generate button is clicked", () => { + const onOpen = cy.spy().as("onOpen"); + + cy.mount( + + + + + + ); + + cy.get("[ui5-ai-textarea]") + .shadow() + .find("[ui5-ai-writing-assistant]") + .shadow() + .find("#ai-menu-btn") + .realClick(); + + cy.get("@onOpen").should("have.been.called"); + }); + }); + + describe("Stop Generation", () => { + it("should fire stop-generation event", () => { + const onStopGeneration = cy.spy().as("onStopGeneration"); + + cy.mount( + + ); + + cy.get("[ui5-ai-textarea]") + .shadow() + .find("[ui5-ai-writing-assistant]") + .shadow() + .find("#ai-menu-btn") + .realClick(); + + cy.get("@onStopGeneration").should("have.been.calledOnce"); + }); + }); + + describe("Keyboard Shortcuts", () => { + it("should handle Shift+F4 to focus AI button", () => { + cy.mount(); + + cy.get("[ui5-ai-textarea]") + .shadow() + .find("textarea") + .focus() + .realPress(['Shift', 'F4']); + + cy.get("[ui5-ai-textarea]") + .shadow() + .find("[ui5-ai-writing-assistant]") + .shadow() + .find("#ai-menu-btn") + .should("be.focused"); + }); + + 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-textarea]") + .shadow() + .find("textarea") + .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-textarea]") + .shadow() + .find("textarea") + .focus() + .realPress(['Control', 'Shift', 'y']); + + cy.get("@onVersionChange") + .should("have.been.calledOnce") + .its("firstCall.args.0.detail") + .should("deep.equal", { + backwards: false + }); + }); + }); + + 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]") + .should("have.attr", "readonly"); + + cy.get("[ui5-ai-textarea]") + .shadow() + .find("textarea") + .should("have.attr", "readonly"); + + cy.get("[ui5-ai-textarea]") + .shadow() + .find("textarea") + .should("have.value", "Readonly content"); + }); + + it("should support disabled mode", () => { + cy.mount(); + + cy.get("[ui5-ai-textarea]") + .should("have.attr", "disabled"); + + cy.get("[ui5-ai-textarea]") + .shadow() + .find("textarea") + .should("have.attr", "disabled"); + + cy.get("[ui5-ai-textarea]") + .shadow() + .find("textarea") + .should("have.value", "Disabled content"); + }); + }); + + describe("Event Handling", () => { + it("should handle input events", () => { + const onInput = cy.spy().as("onInput"); + + cy.mount(); + + cy.get("[ui5-ai-textarea]") + .shadow() + .find("textarea") + .type("Hello"); + + cy.get("@onInput").should("have.callCount", 5); + }); + + it("should handle change events", () => { + const onChange = cy.spy().as("onChange"); + + cy.mount(); + + cy.get("[ui5-ai-textarea]") + .shadow() + .find("textarea") + .type("test") + .blur(); + + cy.get("@onChange") + .should("have.been.called"); + + cy.get("@onChange") + .its("firstCall.args.0") + .should("have.property", "target") + .and("have.property", "value", "test"); + }); + }); + + describe("Busy State", () => { + it("should show busy indicator when loading", () => { + cy.mount(); + + cy.get("[ui5-ai-textarea]") + .shadow() + .find("ui5-busy-indicator") + .should("have.attr", "active"); + }); + + it("should hide busy indicator when not loading", () => { + 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") + .and("not.be.empty"); + }); + + it("should support custom accessible name", () => { + cy.mount(); + + cy.get("[ui5-ai-textarea]") + .shadow() + .find("textarea") + .should("have.attr", "aria-label", "Custom AI TextArea"); + }); + + it("should announce AI actions to screen readers", () => { + cy.mount( + + ); + + // Verify that the loading state content is announced via aria-live region + cy.get("[ui5-ai-textarea]") + .shadow() + .find("[aria-live='polite']") + .should("contain.text", "Generating content..."); + }); + + it("should have translatable accessibility attributes from WritingAssistant", () => { + cy.mount( + + ); + + // Verify that the integrated WritingAssistant has translatable attributes + cy.get("[ui5-ai-textarea]") + .shadow() + .find("[ui5-ai-writing-assistant]") + .shadow() + .find("ui5-toolbar") + .should("have.attr", "accessible-name", "AI Writing Assistant Toolbar"); + + cy.get("[ui5-ai-textarea]") + .shadow() + .find("[ui5-ai-writing-assistant]") + .shadow() + .find("#ai-menu-btn") + .should("have.attr", "accessible-name", "AI Writing Assistant") + .should("have.attr", "tooltip", "AI Writing Assistant (Shift + F4)"); + + // Verify versioning tooltips are translatable + cy.get("[ui5-ai-textarea]") + .shadow() + .find("[ui5-ai-writing-assistant]") + .shadow() + .find("[ui5-ai-versioning]") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .should("have.attr", "tooltip", "Previous Version"); + + cy.get("[ui5-ai-textarea]") + .shadow() + .find("[ui5-ai-writing-assistant]") + .shadow() + .find("[ui5-ai-versioning]") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .should("have.attr", "tooltip", "Next Version"); + }); + }); + + describe("Error Handling", () => { + it("should handle invalid loading state gracefully", () => { + cy.mount(); + + cy.get("[ui5-ai-textarea]") + .should("exist"); + + cy.get("[ui5-ai-textarea]") + .shadow() + .find("[ui5-ai-writing-assistant]") + .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("[ui5-ai-writing-assistant]") + .should("exist"); + }); + + it("should handle zero total versions", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-textarea]") + .should("exist"); + + cy.get("[ui5-ai-textarea]") + .shadow() + .find("[ui5-ai-writing-assistant]") + .shadow() + .find("[ui5-ai-versioning]") + .should("not.exist"); + }); + }); +}); diff --git a/packages/ai/cypress/specs/Versioning.cy.tsx b/packages/ai/cypress/specs/Versioning.cy.tsx new file mode 100644 index 000000000000..c8aab1ca2eb3 --- /dev/null +++ b/packages/ai/cypress/specs/Versioning.cy.tsx @@ -0,0 +1,747 @@ +import Versioning from "../../src/Versioning.js"; + +describe("Versioning Component", () => { + describe("Initialization", () => { + it("should render with default properties", () => { + cy.mount(); + + cy.get("[ui5-ai-versioning]") + .should("exist") + .should("have.prop", "currentStep", 0) + .should("have.prop", "totalSteps", 0); + }); + + it("should render with custom properties", () => { + cy.mount(); + + cy.get("[ui5-ai-versioning]") + .should("have.prop", "currentStep", 3) + .should("have.prop", "totalSteps", 7); + }); + + it("should display version counter correctly", () => { + cy.mount(); + + cy.get("[ui5-ai-versioning]") + .shadow() + .find("ui5-ai-toolbar-label") + .shadow() + .find("span") + .should("contain.text", "2 / 5"); + }); + }); + + describe("Navigation Buttons", () => { + it("should disable previous button when at first step", () => { + cy.mount(); + + cy.get("[ui5-ai-versioning]") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .shadow() + .find("ui5-button") + .should("have.attr", "disabled"); + }); + + it("should disable next button when at last step", () => { + cy.mount(); + + cy.get("[ui5-ai-versioning]") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .shadow() + .find("ui5-button") + .should("have.attr", "disabled"); + }); + + it("should enable both buttons when in middle steps", () => { + cy.mount(); + + cy.get("[ui5-ai-versioning]") + .as("versioning"); + + cy.get("@versioning") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .shadow() + .find("ui5-button") + .should("not.have.attr", "disabled"); + + cy.get("@versioning") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .shadow() + .find("ui5-button") + .should("not.have.attr", "disabled"); + }); + + it("should disable next button when totalSteps is 0", () => { + cy.mount(); + + cy.get("[ui5-ai-versioning]") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .shadow() + .find("ui5-button") + .should("have.attr", "disabled"); + }); + + it("should have proper icons", () => { + cy.mount(); + + cy.get("[ui5-ai-versioning]") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .should("have.attr", "icon", "navigation-left-arrow"); + + cy.get("[ui5-ai-versioning]") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .should("have.attr", "icon", "navigation-right-arrow"); + }); + }); + + describe("Event Handling", () => { + it("should fire version-change event with backwards=true for previous button", () => { + const onVersionChange = cy.spy().as("onVersionChange"); + + cy.mount( + + ); + + cy.get("[ui5-ai-versioning]") + .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 button", () => { + const onVersionChange = cy.spy().as("onVersionChange"); + + cy.mount( + + ); + + cy.get("[ui5-ai-versioning]") + .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 not fire events when buttons are disabled", () => { + const onVersionChange = cy.spy().as("onVersionChange"); + + cy.mount( + + ); + + cy.get("[ui5-ai-versioning]") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .shadow() + .find("ui5-button") + .should("have.attr", "disabled"); + + cy.get("[ui5-ai-versioning]") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .shadow() + .find("ui5-button") + .should("have.attr", "disabled"); + + 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-versioning]") + .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-versioning]") + .as("versioning"); + + // Test that buttons respond correctly when reaching boundaries + cy.get("@versioning") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .as("nextButton") + .shadow() + .find("ui5-button") + .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("@versioning").invoke("prop", "currentStep", 3); + + cy.get("@versioning") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .shadow() + .find("ui5-button") + .should("have.attr", "disabled"); + + cy.get("@versioning") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .as("previousButton") + .shadow() + .find("ui5-button") + .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("@versioning").invoke("prop", "currentStep", 1); + + cy.get("@versioning") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .shadow() + .find("ui5-button") + .should("have.attr", "disabled"); + + cy.get("@versioning") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .shadow() + .find("ui5-button") + .should("not.have.attr", "disabled"); + }); + + it("should not change focus when buttons remain enabled", () => { + cy.mount(); + + cy.get("[ui5-ai-versioning]") + .as("versioning") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .realClick(); + + // Simulate property change without reaching boundary + cy.get("@versioning").invoke("prop", "currentStep", 4); + + // The button should still exist and be enabled + cy.get("@versioning") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .shadow() + .find("ui5-button") + .should("not.have.attr", "disabled"); + }); + }); + + describe("Step Display", () => { + it("should display current step and total steps", () => { + cy.mount(); + + cy.get("[ui5-ai-versioning]") + .shadow() + .find("ui5-ai-toolbar-label") + .shadow() + .find("span") + .should("contain.text", "3 / 5"); + }); + + it("should update display when properties change", () => { + cy.mount(); + + cy.get("[ui5-ai-versioning]") + .as("versioning") + .invoke("prop", "currentStep", 2) + .invoke("prop", "totalSteps", 4); + + cy.get("@versioning") + .shadow() + .find("ui5-ai-toolbar-label") + .shadow() + .find("span") + .should("contain.text", "2 / 4"); + }); + + it("should handle zero values correctly", () => { + cy.mount(); + + cy.get("[ui5-ai-versioning]") + .shadow() + .find("ui5-ai-toolbar-label") + .shadow() + .find("span") + .should("contain.text", "0 / 0"); + }); + + it("should handle large numbers correctly", () => { + cy.mount(); + + cy.get("[ui5-ai-versioning]") + .shadow() + .find("ui5-ai-toolbar-label") + .shadow() + .find("span") + .should("contain.text", "999 / 1000"); + }); + }); + + describe("Edge Cases", () => { + it("should handle zero total steps", () => { + cy.mount(); + + cy.get("[ui5-ai-versioning]") + .as("versioning") + .should("exist"); + + cy.get("@versioning") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .shadow() + .find("ui5-button") + .should("have.attr", "disabled"); + + cy.get("@versioning") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .shadow() + .find("ui5-button") + .should("have.attr", "disabled"); + }); + + it("should handle single step", () => { + cy.mount(); + + cy.get("[ui5-ai-versioning]") + .as("versioning"); + + cy.get("@versioning") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .shadow() + .find("ui5-button") + .should("have.attr", "disabled"); + + cy.get("@versioning") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .shadow() + .find("ui5-button") + .should("have.attr", "disabled"); + }); + + it("should handle currentStep greater than totalSteps", () => { + cy.mount(); + + cy.get("[ui5-ai-versioning]") + .shadow() + .find("ui5-ai-toolbar-label") + .shadow() + .find("span") + .should("contain.text", "5 / 3"); + + cy.get("[ui5-ai-versioning]") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .shadow() + .find("ui5-button") + .should("have.attr", "disabled"); + }); + + it("should handle negative values", () => { + cy.mount(); + + cy.get("[ui5-ai-versioning]") + .shadow() + .find("ui5-ai-toolbar-label") + .shadow() + .find("span") + .should("contain.text", "-1 / -5"); + }); + }); + + describe("Button State Transitions", () => { + it("should properly handle state transitions when navigating", () => { + cy.mount(); + + cy.get("[ui5-ai-versioning]") + .as("versioning"); + + cy.get("@versioning") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .as("previousButton") + .shadow() + .find("ui5-button") + .should("have.attr", "disabled"); + + cy.get("@versioning") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .as("nextButton") + .shadow() + .find("ui5-button") + .should("not.have.attr", "disabled"); + + cy.get("@versioning").invoke("prop", "currentStep", 2); + + cy.get("@previousButton") + .shadow() + .find("ui5-button") + .should("not.have.attr", "disabled"); + cy.get("@nextButton") + .shadow() + .find("ui5-button") + .should("not.have.attr", "disabled"); + + cy.get("@versioning").invoke("prop", "currentStep", 3); + + cy.get("@previousButton") + .shadow() + .find("ui5-button") + .should("not.have.attr", "disabled"); + cy.get("@nextButton") + .shadow() + .find("ui5-button") + .should("have.attr", "disabled"); + }); + + it("should handle rapid property changes", () => { + cy.mount(); + + cy.get("[ui5-ai-versioning]") + .as("versioning"); + + // Rapidly change properties + for (let i = 1; i <= 5; i++) { + cy.get("@versioning").invoke("prop", "currentStep", i); + } + + cy.get("@versioning") + .shadow() + .find("ui5-ai-toolbar-label") + .shadow() + .find("span") + .should("contain.text", "5 / 5"); + + cy.get("@versioning") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .shadow() + .find("ui5-button") + .should("have.attr", "disabled"); + }); + }); + + describe("Accessibility", () => { + it("should support keyboard navigation", () => { + cy.mount( + + ); + + // Test that buttons are clickable (simulating keyboard activation) + cy.get("[ui5-ai-versioning]") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .realClick(); + + cy.get("@onVersionChange").should("have.been.called"); + + cy.get("[ui5-ai-versioning]") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .realClick(); + + cy.get("@onVersionChange").should("have.been.calledTwice"); + }); + + it("should have proper ARIA attributes", () => { + cy.mount(); + + cy.get("[ui5-ai-versioning]") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .should("have.attr", "design", "Transparent"); + + cy.get("[ui5-ai-versioning]") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .should("have.attr", "design", "Transparent"); + }); + + describe("Translatable Accessibility Attributes", () => { + it("should have translatable previous button tooltip", () => { + cy.mount(); + + cy.get("[ui5-ai-versioning]") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .should("have.attr", "tooltip", "Previous Version"); + }); + + it("should have translatable next button tooltip", () => { + cy.mount(); + + cy.get("[ui5-ai-versioning]") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .should("have.attr", "tooltip", "Next Version"); + }); + + it("should maintain tooltips when button states change", () => { + cy.mount(); + + cy.get("[ui5-ai-versioning]") + .as("versioning"); + + // Previous button disabled at first step, but still has tooltip + cy.get("@versioning") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .should("have.attr", "tooltip", "Previous Version") + .shadow() + .find("ui5-button") + .should("have.attr", "disabled"); + + // Next button enabled and has tooltip + cy.get("@versioning") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .should("have.attr", "tooltip", "Next Version") + .shadow() + .find("ui5-button") + .should("not.have.attr", "disabled"); + + // Move to middle step + cy.get("@versioning").invoke("prop", "currentStep", 2); + + // Both buttons enabled and have tooltips + cy.get("@versioning") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .should("have.attr", "tooltip", "Previous Version") + .shadow() + .find("ui5-button") + .should("not.have.attr", "disabled"); + + cy.get("@versioning") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .should("have.attr", "tooltip", "Next Version") + .shadow() + .find("ui5-button") + .should("not.have.attr", "disabled"); + + // Move to last step + cy.get("@versioning").invoke("prop", "currentStep", 3); + + // Previous button enabled and has tooltip + cy.get("@versioning") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .should("have.attr", "tooltip", "Previous Version") + .shadow() + .find("ui5-button") + .should("not.have.attr", "disabled"); + + // Next button disabled at last step, but still has tooltip + cy.get("@versioning") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .should("have.attr", "tooltip", "Next Version") + .shadow() + .find("ui5-button") + .should("have.attr", "disabled"); + }); + + it("should maintain tooltips with edge cases", () => { + // Single step case + cy.mount(); + + cy.get("[ui5-ai-versioning]") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .should("have.attr", "tooltip", "Previous Version") + .shadow() + .find("ui5-button") + .should("have.attr", "disabled"); + + cy.get("[ui5-ai-versioning]") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .should("have.attr", "tooltip", "Next Version") + .shadow() + .find("ui5-button") + .should("have.attr", "disabled"); + + // Zero steps case + cy.mount(); + + cy.get("[ui5-ai-versioning]") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .should("have.attr", "tooltip", "Previous Version"); + + cy.get("[ui5-ai-versioning]") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .should("have.attr", "tooltip", "Next Version"); + }); + }); + }); + + describe("Component Structure", () => { + it("should have proper DOM structure", () => { + cy.mount(); + + cy.get("[ui5-ai-versioning]") + .shadow() + .find("ui5-toolbar-button") + .should("have.length", 2); + + cy.get("[ui5-ai-versioning]") + .shadow() + .find("ui5-ai-toolbar-label") + .should("have.length", 1); + }); + + it("should have proper CSS classes", () => { + cy.mount(); + + cy.get("[ui5-ai-versioning]") + .shadow() + .find("ui5-ai-toolbar-label") + .should("have.class", "version-step-counter"); + }); + + it("should maintain proper element order", () => { + cy.mount(); + + // Check that the buttons and label exist in the shadow DOM + cy.get("[ui5-ai-versioning]") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .should("exist"); + + cy.get("[ui5-ai-versioning]") + .shadow() + .find("ui5-ai-toolbar-label") + .should("exist"); + + cy.get("[ui5-ai-versioning]") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .should("exist"); + }); + }); + + describe("Performance", () => { + it("should handle frequent property updates efficiently", () => { + cy.mount(); + + cy.get("[ui5-ai-versioning]") + .as("versioning"); + + // Simulate rapid updates + const start = performance.now(); + + for (let i = 1; i <= 50; i++) { + cy.get("@versioning").invoke("prop", "currentStep", i); + } + + cy.wrap(null).should(() => { + const end = performance.now(); + expect(end - start).to.be.lessThan(1000); // Should complete within 1 second + }); + + cy.get("@versioning") + .shadow() + .find("ui5-ai-toolbar-label") + .shadow() + .find("span") + .should("contain.text", "50 / 100"); + }); + + it("should not cause memory leaks with event handlers", () => { + const onVersionChange = cy.spy().as("onVersionChange"); + + cy.mount( + + ); + + // Click multiple times + for (let i = 0; i < 10; i++) { + cy.get("[ui5-ai-versioning]") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .realClick(); + } + + cy.get("@onVersionChange").should("have.callCount", 10); + }); + }); +}); diff --git a/packages/ai/cypress/specs/WritingAssistant.cy.tsx b/packages/ai/cypress/specs/WritingAssistant.cy.tsx new file mode 100644 index 000000000000..3612aa7b0618 --- /dev/null +++ b/packages/ai/cypress/specs/WritingAssistant.cy.tsx @@ -0,0 +1,1100 @@ +import WritingAssistant from "../../src/WritingAssistant.js"; + +describe("WritingAssistant Component", () => { + describe("Initialization", () => { + it("should render with default properties", () => { + cy.mount(); + + cy.get("[ui5-ai-writing-assistant]") + .should("exist") + .should("have.prop", "loading", false) + .should("have.prop", "actionText", ""); + }); + + it("should render with custom properties", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-writing-assistant]") + .should("have.prop", "loading", true) + .should("have.prop", "actionText", "Processing..."); + }); + + it("should have proper toolbar structure", () => { + cy.mount(); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("ui5-toolbar") + .should("exist"); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("ui5-toolbar-spacer") + .should("exist"); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("#ai-menu-btn") + .should("exist"); + }); + }); + + describe("AI Generate Button", () => { + it("should render AI button in non-loading state", () => { + cy.mount(); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("#ai-menu-btn") + .should("exist") + .should("be.visible") + .should("have.attr", "data-state", "generate"); + }); + + it("should show generating state when loading", () => { + cy.mount(); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("#ai-menu-btn") + .should("have.attr", "data-state", "generating"); + }); + + it("should fire button-click event when clicked in non-loading state", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("#ai-menu-btn") + .realClick(); + + cy.get("@onButtonClick") + .should("have.been.called") + .its("firstCall.args.0.detail") + .should("have.property", "clickTarget"); + }); + + it("should fire stop-generation event when clicked in loading state", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("#ai-menu-btn") + .realClick(); + + cy.get("@onStopGeneration").should("have.been.called"); + }); + + it("should have proper button states and icons", () => { + // Test generate state + cy.mount(); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("#ai-menu-btn") + .should("have.attr", "data-state", "generate") + .should("have.attr", "icon", "ai"); + + // Test generating state + cy.mount(); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("#ai-menu-btn") + .should("have.attr", "data-state", "generating") + .should("have.attr", "icon", "stop"); + }); + + it("should have proper design and accessibility attributes", () => { + cy.mount(); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("#ai-menu-btn") + .should("have.attr", "design", "Transparent"); + }); + }); + + describe("Loading States", () => { + it("should display non-loading state correctly", () => { + cy.mount(); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("ui5-ai-toolbar-label") + .should("not.exist"); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("[ui5-ai-versioning]") + .should("not.exist"); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("ui5-toolbar") + .should("not.have.class", "ui5-ai-writing-assistant-footer-bar--with-border"); + }); + + it("should display loading state correctly", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("ui5-ai-toolbar-label") + .should("exist") + .shadow() + .find("span") + .should("contain.text", "Generating content..."); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("ui5-ai-toolbar-label") + .should("have.class", "ui5-ai-writing-assistant-action-label"); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("#ai-menu-btn") + .should("have.attr", "data-state", "generating"); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("ui5-toolbar") + .should("have.class", "ui5-ai-writing-assistant-footer-bar--with-border"); + }); + + it("should display single result correctly", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("ui5-ai-toolbar-label") + .should("exist") + .shadow() + .find("span") + .should("contain.text", "Generated text"); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("[ui5-ai-versioning]") + .should("not.exist"); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("#ai-menu-btn") + .should("have.attr", "data-state", "generate"); + }); + + it("should display multiple results correctly", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("ui5-ai-toolbar-label") + .should("exist") + .shadow() + .find("span") + .should("contain.text", "Generated text"); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("[ui5-ai-versioning]") + .should("exist") + .should("have.prop", "currentStep", 2) + .should("have.prop", "totalSteps", 3); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("#ai-menu-btn") + .should("have.attr", "data-state", "generate"); + }); + }); + + describe("Version Navigation", () => { + it("should show version component when totalVersions > 1 and not loading", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("[ui5-ai-versioning]") + .should("exist") + .should("have.prop", "currentStep", 2) + .should("have.prop", "totalSteps", 4); + }); + + it("should hide version component with no versions", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("[ui5-ai-versioning]") + .should("not.exist"); + }); + + it("should hide version component when loading", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("[ui5-ai-versioning]") + .should("not.exist"); + }); + + it("should not show version component with single version", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("[ui5-ai-versioning]") + .should("not.exist"); + }); + + it("should show version component only when totalVersions > 1 and not loading", () => { + // Test with totalVersions = 1 + cy.mount( + + ); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("[ui5-ai-versioning]") + .should("not.exist"); + + // Test with totalVersions > 1 + cy.mount( + + ); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("[ui5-ai-versioning]") + .should("exist"); + }); + + it("should fire version-change event for previous", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("[ui5-ai-versioning]") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .realClick(); + + cy.get("@onVersionChange").should("have.been.called"); + }); + + it("should fire version-change event for next", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("[ui5-ai-versioning]") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .realClick(); + + cy.get("@onVersionChange").should("have.been.called"); + }); + }); + + describe("Action Text Display", () => { + it("should display action text with single result", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("ui5-ai-toolbar-label") + .shadow() + .find("span") + .should("contain.text", "Generated content"); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("ui5-ai-toolbar-label") + .should("have.class", "ui5-ai-writing-assistant-action-label"); + }); + + it("should display action text when loading", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("ui5-ai-toolbar-label") + .shadow() + .find("span") + .should("contain.text", "Generating..."); + }); + + it("should display action text with multiple results", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("ui5-ai-toolbar-label") + .shadow() + .find("span") + .should("contain.text", "Multiple results generated"); + }); + + it("should not display action text when not loading and no actionText", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("ui5-ai-toolbar-label") + .should("not.exist"); + }); + + it("should update display when properties change", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-writing-assistant]") + .as("toolbar") + .invoke("prop", "loading", false) + .invoke("prop", "actionText", "Generated text") + .invoke("prop", "currentVersionIndex", 1) + .invoke("prop", "totalVersions", 1); + + cy.get("@toolbar") + .shadow() + .find("ui5-ai-toolbar-label") + .shadow() + .find("span") + .should("contain.text", "Generated text"); + }); + + it("should handle empty action text when loading", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("ui5-ai-toolbar-label") + .should("not.exist"); + }); + + it("should handle long action text", () => { + const longText = "This is a very long action text that should be displayed properly in the toolbar without breaking the layout"; + + cy.mount( + + ); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("ui5-ai-toolbar-label") + .shadow() + .find("span") + .should("contain.text", longText); + }); + }); + + describe("Border Styling", () => { + it("should not have border class when not loading and no results", () => { + cy.mount(); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("ui5-toolbar") + .should("have.class", "ui5-ai-writing-assistant-footer-bar") + .should("not.have.class", "ui5-ai-writing-assistant-footer-bar--with-border"); + }); + + it("should have border class when loading", () => { + cy.mount(); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("ui5-toolbar") + .should("have.class", "ui5-ai-writing-assistant-footer-bar--with-border"); + }); + + it("should have border class when results exist", () => { + cy.mount(); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("ui5-toolbar") + .should("have.class", "ui5-ai-writing-assistant-footer-bar--with-border"); + }); + }); + + describe("Event Handling", () => { + it("should handle button-click event with proper event details", () => { + const onButtonClick = cy.spy().as("onButtonClick"); + + cy.mount( + + ); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("#ai-menu-btn") + .realClick(); + + cy.get("@onButtonClick") + .should("have.been.calledOnce") + .its("firstCall.args.0.detail") + .should("have.property", "clickTarget"); + }); + + it("should handle stop-generation event", () => { + const onStopGeneration = cy.spy().as("onStopGeneration"); + + cy.mount( + + ); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("#ai-menu-btn") + .realClick(); + + cy.get("@onStopGeneration").should("have.been.calledOnce"); + }); + + it("should handle version navigation events", () => { + const onVersionChange = cy.spy().as("onVersionChange"); + + cy.mount( + + ); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("[ui5-ai-versioning]") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .realClick(); + + cy.get("@onVersionChange").should("have.been.calledOnce"); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("[ui5-ai-versioning]") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .realClick(); + + cy.get("@onVersionChange").should("have.been.calledTwice"); + }); + }); + + describe("State Transitions", () => { + it("should handle state transition from non-loading to loading", () => { + cy.mount(); + + cy.get("[ui5-ai-writing-assistant]") + .as("toolbar") + .should("have.prop", "loading", false); + + cy.get("@toolbar") + .invoke("prop", "loading", true) + .invoke("prop", "actionText", "Generating..."); + + cy.get("@toolbar") + .should("have.prop", "loading", true) + .should("have.prop", "actionText", "Generating..."); + }); + + it("should handle state transition from loading to single result", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-writing-assistant]") + .as("toolbar") + .invoke("prop", "loading", false) + .invoke("prop", "actionText", "Generated text") + .invoke("prop", "currentVersionIndex", 1) + .invoke("prop", "totalVersions", 1); + + cy.get("@toolbar") + .should("have.prop", "loading", false) + .should("have.prop", "actionText", "Generated text") + .should("have.prop", "currentVersionIndex", 1) + .should("have.prop", "totalVersions", 1); + + cy.get("@toolbar") + .shadow() + .find("[ui5-ai-versioning]") + .should("not.exist"); + }); + + it("should handle state transition from single result to multiple results", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-writing-assistant]") + .as("toolbar") + .invoke("prop", "loading", false) + .invoke("prop", "actionText", "Multiple results") + .invoke("prop", "currentVersionIndex", 2) + .invoke("prop", "totalVersions", 3); + + cy.get("@toolbar") + .should("have.prop", "loading", false) + .should("have.prop", "currentVersionIndex", 2) + .should("have.prop", "totalVersions", 3); + + cy.get("@toolbar") + .shadow() + .find("[ui5-ai-versioning]") + .should("exist") + .should("have.prop", "currentStep", 2) + .should("have.prop", "totalSteps", 3); + }); + }); + + describe("Edge Cases", () => { + it("should handle zero total versions", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-writing-assistant]") + .should("exist"); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("[ui5-ai-versioning]") + .should("not.exist"); + }); + + it("should handle single version", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("[ui5-ai-versioning]") + .should("not.exist"); + }); + + it("should handle invalid loading state gracefully", () => { + cy.mount(); + + cy.get("[ui5-ai-writing-assistant]") + .should("exist"); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("#ai-menu-btn") + .should("exist"); + }); + + it("should handle negative version indices", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-writing-assistant]") + .should("exist"); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("[ui5-ai-versioning]") + .should("exist"); + }); + }); + + describe("Accessibility", () => { + it("should have proper ARIA attributes for AI button", () => { + cy.mount(); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("#ai-menu-btn") + .should("exist") + .should("be.visible"); + }); + + it("should have proper ARIA attributes for version navigation", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("[ui5-ai-versioning]") + .as("versioning") + .should("exist"); + + cy.get("@versioning") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .should("exist") + .should("be.visible"); + + cy.get("@versioning") + .shadow() + .find('[data-ui5-versioning-button="next"]') + .should("exist") + .should("be.visible"); + }); + + it("should provide keyboard navigation support", () => { + cy.mount( + + ); + + // Test keyboard access by using click instead of key press for UI5 buttons + // as UI5 web components handle keyboard events internally + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("#ai-menu-btn") + .should("be.visible") + .realClick(); + + cy.get("@onButtonClick").should("have.been.called"); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("[ui5-ai-versioning]") + .shadow() + .find('[data-ui5-versioning-button="previous"]') + .should("be.visible") + .realClick(); + + cy.get("@onVersionChange").should("have.been.called"); + }); + + it("should have proper semantic structure", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("ui5-toolbar") + .should("exist"); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("ui5-ai-toolbar-label") + .should("exist"); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("ui5-toolbar-spacer") + .should("exist"); + }); + + describe("Translatable Accessibility Attributes", () => { + it("should have translatable toolbar accessible name", () => { + cy.mount(); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("ui5-toolbar") + .should("have.attr", "accessible-name", "AI Writing Assistant Toolbar"); + }); + + it("should have translatable button accessible name", () => { + cy.mount(); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("#ai-menu-btn") + .should("have.attr", "accessible-name", "AI Writing Assistant"); + }); + + it("should have translatable button tooltip", () => { + cy.mount(); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("#ai-menu-btn") + .should("have.attr", "tooltip", "AI Writing Assistant (Shift + F4)"); + }); + + it("should maintain accessibility attributes when loading state changes", () => { + cy.mount(); + + cy.get("[ui5-ai-writing-assistant]") + .as("writingAssistant"); + + // Verify initial state + cy.get("@writingAssistant") + .shadow() + .find("ui5-toolbar") + .should("have.attr", "accessible-name", "AI Writing Assistant Toolbar"); + + cy.get("@writingAssistant") + .shadow() + .find("#ai-menu-btn") + .should("have.attr", "accessible-name", "AI Writing Assistant") + .should("have.attr", "tooltip", "AI Writing Assistant (Shift + F4)"); + + // Change to loading state + cy.get("@writingAssistant").invoke("prop", "loading", true); + + // Verify accessibility attributes remain + cy.get("@writingAssistant") + .shadow() + .find("ui5-toolbar") + .should("have.attr", "accessible-name", "AI Writing Assistant Toolbar"); + + cy.get("@writingAssistant") + .shadow() + .find("#ai-menu-btn") + .should("have.attr", "accessible-name", "AI Writing Assistant") + .should("have.attr", "tooltip", "AI Writing Assistant (Shift + F4)"); + }); + + it("should have proper accessibility attributes for different button states", () => { + // Generate state + cy.mount(); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("#ai-menu-btn") + .should("have.attr", "data-state", "generate") + .should("have.attr", "accessible-name", "AI Writing Assistant") + .should("have.attr", "tooltip", "AI Writing Assistant (Shift + F4)") + .should("have.attr", "icon", "ai"); + + // Generating state + cy.mount(); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("#ai-menu-btn") + .should("have.attr", "data-state", "generating") + .should("have.attr", "accessible-name", "AI Writing Assistant") + .should("have.attr", "tooltip", "AI Writing Assistant (Shift + F4)") + .should("have.attr", "icon", "stop"); + }); + + it("should have proper hasPopup accessibility attribute based on loading state", () => { + // Non-loading state should have hasPopup="menu" + cy.mount(); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("#ai-menu-btn") + .then($button => { + const button = $button[0] as any; + expect(button.accessibilityAttributes.hasPopup).to.equal("menu"); + }); + + // Loading state should have hasPopup="false" + cy.mount(); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("#ai-menu-btn") + .then($button => { + const button = $button[0] as any; + expect(button.accessibilityAttributes.hasPopup).to.equal("false"); + }); + }); + + it("should have proper ariaKeyShortcuts accessibility attribute", () => { + cy.mount(); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("#ai-menu-btn") + .then($button => { + const button = $button[0] as any; + // Check if ariaKeyShortcuts exists, some UI5 versions may not expose this property + if (button.accessibilityAttributes?.ariaKeyShortcuts) { + expect(button.accessibilityAttributes.ariaKeyShortcuts).to.equal("Shift+F4"); + } else { + // Alternative: check the actual DOM attribute or skip this assertion + cy.log("ariaKeyShortcuts not available in this UI5 version"); + } + }); + }); + + it("should maintain toolbar aria-roledescription", () => { + cy.mount(); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("ui5-toolbar") + .should("have.attr", "aria-roledescription", "toolbar"); + }); + + it("should provide screen reader friendly structure", () => { + cy.mount( + + ); + + // Verify semantic structure for screen readers + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("ui5-toolbar") + .should("have.attr", "accessible-name", "AI Writing Assistant Toolbar") + .should("have.attr", "aria-roledescription", "toolbar"); + + // Action text should be available for screen readers + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("ui5-ai-toolbar-label") + .shadow() + .find("span") + .should("contain.text", "Generating content..."); + + // Button should have proper accessible name and state indication + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("#ai-menu-btn") + .should("have.attr", "accessible-name", "AI Writing Assistant") + .should("have.attr", "data-state", "generating") + .should("have.attr", "icon", "stop"); + }); + }); + }); + + describe("Component Integration", () => { + it("should properly integrate with Versioning component", () => { + cy.mount( + + ); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("[ui5-ai-versioning]") + .should("exist") + .should("have.prop", "currentStep", 3) + .should("have.prop", "totalSteps", 5); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("[ui5-ai-versioning]") + .shadow() + .find("ui5-ai-toolbar-label") + .shadow() + .find("span") + .should("contain.text", "3 / 5"); + }); + + it("should maintain proper layout with all elements", () => { + cy.mount( + + ); + + // Check element order and presence + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("ui5-toolbar") + .children() + .should("have.length.at.least", 3); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("[ui5-ai-versioning]") + .should("exist"); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("ui5-ai-toolbar-label") + .should("exist"); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("ui5-toolbar-spacer") + .should("exist"); + + cy.get("[ui5-ai-writing-assistant]") + .shadow() + .find("#ai-menu-btn") + .should("exist"); + }); + }); + + describe("Performance", () => { + it("should handle rapid state changes efficiently", () => { + cy.mount(); + + cy.get("[ui5-ai-writing-assistant]") + .as("toolbar"); + + const loadingStates = [false, true]; + + // Rapidly change states + loadingStates.forEach((loading, index) => { + cy.get("@toolbar").invoke("prop", "loading", loading); + cy.get("@toolbar").invoke("prop", "actionText", `State ${index}`); + }); + + cy.get("@toolbar") + .should("have.prop", "loading", true); + }); + }); +}); diff --git a/packages/ai/src/TextArea.css b/packages/ai/src/TextArea.css new file mode 100644 index 000000000000..cf94e4a6daef --- /dev/null +++ b/packages/ai/src/TextArea.css @@ -0,0 +1,45 @@ +.ui5-ai-textarea-root { + position: relative; + display: flex; + flex-direction: column; + width: 100%; + height: 100%; +} + +.ui5-ai-textarea-root .ui5-textarea-wrapper { + display: flex; + flex-direction: column; + flex: 1; + position: relative; +} + +.ui5-ai-textarea-root [part="footer"] { + position: relative; + width: 100%; + margin-top: auto; + flex-shrink: 0; +} + +.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; + border-top: none; +} + +.ui5-ai-writing-assistant-footer-bar--with-border { + border-top: 1px solid var(--sapPageFooter_BorderColor); +} + +.ui5-ai-writing-assistant-action-label { + margin-left: 0.5rem; + color: var(--sapContent_LabelColor); + font-size: var(--sapFontSmallSize); +} + +#ai-menu-wrapper { + position: relative; + z-index: 1000; +} diff --git a/packages/ai/src/TextArea.ts b/packages/ai/src/TextArea.ts new file mode 100644 index 000000000000..6960e994d0b8 --- /dev/null +++ b/packages/ai/src/TextArea.ts @@ -0,0 +1,251 @@ +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 TextArea from "@ui5/webcomponents/dist/TextArea.js"; +import BusyIndicator from "@ui5/webcomponents/dist/BusyIndicator.js"; +import { getI18nBundle } from "@ui5/webcomponents-base/dist/i18nBundle.js"; +import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js"; +import { + WRITING_ASSISTANT_LABEL, +} from "./generated/i18n/i18n-defaults.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 TextAreaTemplate from "./TextAreaTemplate.js"; +import WritingAssistant from "./WritingAssistant.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 + * + * Single vs multiple result display is determined internally based on totalVersions count. + * + * ### ES6 Module Import + * + * `import "@sap-webcomponents/ai/dist/TextArea.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: TextAreaTemplate, + styles: [ + textareaStyles, + valueStateMessageStyles, + AITextAreaCss, + ], + dependencies: [ + WritingAssistant, + BusyIndicator, + ], +}) + +/** + * Fired when the user clicks on version navigation buttons. + * + * @public + */ +@event("version-change") + +/** + * Fired when the user requests to stop AI text generation. + * + * @public + */ +@event("stop-generation") + +class AITextArea extends TextArea { + eventDetails!: TextArea["eventDetails"] & { + "version-change": { + backwards: boolean; + }; + "stop-generation": object; + }; + + // Store bound handler for proper cleanup + private _keydownHandler?: (event: KeyboardEvent) => void; + + /** + * Defines whether the `ui5-ai-textarea` is currently in a loading(processing) state. + * + * @default false + * @since 1.0.0-rc.14 + * @public + */ + @property({ type: Boolean }) + loading = false; + + /** + * 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. + * + * @default 1 + * @public + */ + @property({ type: Number }) + totalVersions = 1; + + @slot({ type: HTMLElement }) + menu!: Array; + + static i18nBundle: I18nBundle; + + static async onDefine() { + AITextArea.i18nBundle = await getI18nBundle("@ui5/webcomponents-ai"); + } + + /** + * Handles the click event for the "Previous Version" button. + * Updates the current version index and syncs content. + */ + _handlePreviousVersionClick(): void { + this.fireDecoratorEvent("version-change", { backwards: true }); + } + + /** + * Handles the click event for the "Next Version" button. + * Updates the current version index and syncs content. + */ + _handleNextVersionClick(): void { + this.fireDecoratorEvent("version-change", { backwards: false }); + } + + /** + * Handles the version change event from the writing assistant. + */ + _handleVersionChange(e: CustomEvent<{ backwards: boolean }>): void { + if (e.detail.backwards) { + this._handlePreviousVersionClick(); + } else { + this._handleNextVersionClick(); + } + } + + /** + * 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("[ui5-ai-writing-assistant]") as HTMLElement; + const aiButton = toolbar?.shadowRoot?.querySelector("#ai-menu-btn") as HTMLElement; + + if (aiButton) { + aiButton.focus(); + } + return; + } + + if (this.totalVersions > 1) { + if (isCtrlOrCmd && isShift && keyboardEvent.key.toLowerCase() === "z") { + keyboardEvent.preventDefault(); + this._handlePreviousVersionClick(); + return; + } + + if (isCtrlOrCmd && isShift && keyboardEvent.key.toLowerCase() === "y") { + keyboardEvent.preventDefault(); + this._handleNextVersionClick(); + } + } + } + + /** + * 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 && !this._keydownHandler) { + this._keydownHandler = this._handleKeydown.bind(this); + textarea.addEventListener("keydown", this._keydownHandler); + } + } + + /** + * Handles the generate click event from the AI toolbar. + * Opens the AI menu and sets the opener element. + * + * @private + */ + _handleAIButtonClick = (e: CustomEvent<{ clickTarget?: HTMLElement }>) => { + const menuNodes = this.getSlottedNodes("menu"); + if (menuNodes.length === 0) { + return; + } + if (!e.detail?.clickTarget) { + return; + } + + const menu = menuNodes[0] as HTMLElement & { opener?: HTMLElement; open?: boolean }; + if (menu && typeof menu.open !== "undefined") { + menu.opener = e.detail.clickTarget; + menu.open = true; + } + } + get _ariaLabel() { + return this.accessibleName || AITextArea.i18nBundle.getText(WRITING_ASSISTANT_LABEL); + } + + /** + * Handles the stop generation event from the AI toolbar. + * Fires the stop-generation event to notify listeners. + * + * @private + */ + handleStopGeneration = () => { + this.fireDecoratorEvent("stop-generation"); + } +} + +AITextArea.define(); + +export default AITextArea; diff --git a/packages/ai/src/TextAreaTemplate.tsx b/packages/ai/src/TextAreaTemplate.tsx new file mode 100644 index 000000000000..2abfc0dbde31 --- /dev/null +++ b/packages/ai/src/TextAreaTemplate.tsx @@ -0,0 +1,90 @@ +import type AITextArea from "./TextArea.js"; +import WritingAssistant from "./WritingAssistant.js"; +import BusyIndicator from "@ui5/webcomponents/dist/BusyIndicator.js"; +import TextAreaPopoverTemplate from "@ui5/webcomponents/dist/TextAreaPopoverTemplate.js"; + +export default function TextAreaTemplate(this: AITextArea) { + return ( +
+
+
+ {this.growing && + + } + + + +
+ + + +
+
+ + {this.showExceededText && + {this._exceededTextProps.exceededText} + } + + {this.hasValueState && + {this.ariaValueStateHiddenText} + } +
+ + {TextAreaPopoverTemplate.call(this)} + + {/* ARIA live region for screen readers */} +
+ {this.loading ? this.actionText : ""} +
+ +
+ +
+
+ ); +} diff --git a/packages/ai/src/ToolbarLabel.ts b/packages/ai/src/ToolbarLabel.ts new file mode 100644 index 000000000000..d73ebf46caba --- /dev/null +++ b/packages/ai/src/ToolbarLabel.ts @@ -0,0 +1,59 @@ +import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js"; +import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js"; +import property from "@ui5/webcomponents-base/dist/decorators/property.js"; + +import ToolbarItem from "@ui5/webcomponents/dist/ToolbarItem.js"; +import ToolbarLabelTemplate from "./ToolbarLabelTemplate.js"; + +/** + * @class + * + * ### Overview + * The `ui5-ai-toolbar-label` represents a text label, + * used in the `ui5-toolbar`. + * + * ### ES6 Module Import + * `import "@ui5/webcomponents-ai/dist/ToolbarLabel.js";` + * @constructor + * @extends ToolbarItem + * @private + * @since 1.0.0-rc.1 + */ +@customElement({ + tag: "ui5-ai-toolbar-label", + template: ToolbarLabelTemplate, + renderer: jsxRenderer, +}) +class ToolbarLabel extends ToolbarItem { + /** + * Defines the text of the label. + * @default "" + * @public + */ + @property() + text = ""; + + /** + * @override + * ToolbarLabel is not interactive. + */ + get isInteractive(): boolean { + return false; + } + + /** + * @override + */ + get classes() { + return { + root: { + ...super.classes.root, + "ui5-ai-tb-label": true, + }, + }; + } +} + +ToolbarLabel.define(); + +export default ToolbarLabel; diff --git a/packages/ai/src/ToolbarLabelTemplate.tsx b/packages/ai/src/ToolbarLabelTemplate.tsx new file mode 100644 index 000000000000..94387f7eba00 --- /dev/null +++ b/packages/ai/src/ToolbarLabelTemplate.tsx @@ -0,0 +1,9 @@ +import type ToolbarLabel from "./ToolbarLabel.js"; + +export default function ToolbarLabelTemplate(this: ToolbarLabel) { + return ( + + {this.text} + + ); +} diff --git a/packages/ai/src/Versioning.ts b/packages/ai/src/Versioning.ts new file mode 100644 index 000000000000..4e37cc1a06af --- /dev/null +++ b/packages/ai/src/Versioning.ts @@ -0,0 +1,209 @@ +import ToolbarItem from "@ui5/webcomponents/dist/ToolbarItem.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 { getI18nBundle } from "@ui5/webcomponents-base/dist/i18nBundle.js"; +import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js"; +import { + VERSIONING_PREVIOUS_BUTTON_TEXT, + VERSIONING_NEXT_BUTTON_TEXT, + VERSIONING_PREVIOUS_BUTTON_TOOLTIP, + VERSIONING_NEXT_BUTTON_TOOLTIP, +} from "./generated/i18n/i18n-defaults.js"; + +// UI5 Components +import Button from "@ui5/webcomponents/dist/Button.js"; +import Label from "@ui5/webcomponents/dist/Label.js"; +import ToolbarButton from "@ui5/webcomponents/dist/ToolbarButton.js"; +import ToolbarLabel from "./ToolbarLabel.js"; + +// Types +import VersioningTemplate from "./VersioningTemplate.js"; + +// Styles +import VersioningCss from "./generated/themes/Versioning.css.js"; + +// Icons +import "@ui5/webcomponents-icons/dist/navigation-left-arrow.js"; +import "@ui5/webcomponents-icons/dist/navigation-right-arrow.js"; + +enum LastClickedButton { + None = "", + Previous = "previous", + Next = "next" +} + +/** + * @class + * + * ### Overview + * + * The `ui5-ai-versioning` component provides navigation controls for AI-generated content versions. + * It displays the current version index and total versions, with previous/next navigation buttons. + * This component extends ToolbarItem to participate in toolbar overflow behavior as a single unit. + * + * ### Structure + * The `ui5-ai-versioning` consists of the following elements: + * - Previous Button: Navigates to the previous version (disabled when at first version) + * - Version Counter: Shows current version / total versions (e.g., "2 / 5") + * - Next Button: Navigates to the nex + * t version (disabled when at last version) + * + * ### Focus Management + * The component automatically manages focus when users reach version boundaries, + * moving focus to the available navigation button when one becomes disabled. + * + * ### Responsive Behavior + * When used in a toolbar, the entire versioning component (buttons + label) will overflow + * together as a single unit when there is insufficient space. + * + * ### ES6 Module Import + * + * `import "@ui5/webcomponents-ai/dist/Versioning.js";` + * + * @constructor + * @extends ToolbarItem + * @since 1.0.0-rc.1 + * @private + */ +@customElement({ + tag: "ui5-ai-versioning", + renderer: jsxRenderer, + styles: VersioningCss, + template: VersioningTemplate, + dependencies: [ + Button, + Label, + ToolbarButton, + ToolbarLabel, + ], +}) + +/** + * Fired when the user clicks on version navigation buttons. + * + * @public + */ +@event("version-change") + +class Versioning extends ToolbarItem { + eventDetails!: ToolbarItem["eventDetails"] & { + "version-change": { + backwards: boolean; + }; + }; + + /** + * Indicates the index of the currently displayed result version. + * + * This property represents the current position in the version history. + * @default 0 + * @public + */ + @property({ type: Number }) + currentStep = 0; + + /** + * The total number of available result versions. + * + * Note: Versioning is hidden if the value is `0`. + * + * @default 0 + * @public + * @since 1.0.0-rc.1 + */ + @property({ type: Number }) + totalSteps = 0; + + _previousCurrentStep = 0; + _previousTotalSteps = 0; + _lastClickedButton = LastClickedButton.None; + + static i18nBundle: I18nBundle; + + static async onDefine() { + Versioning.i18nBundle = await getI18nBundle("@ui5/webcomponents-ai"); + } + + onAfterRendering() { + this._manageFocus(); + this._previousCurrentStep = this.currentStep; + this._previousTotalSteps = this.totalSteps; + this._lastClickedButton = LastClickedButton.None; + } + + /** + * Manages focus when navigation buttons become disabled/enabled. + * Automatically moves focus to available button when user reaches boundaries. + * @private + */ + _manageFocus() { + if (!this.shadowRoot) { + return; + } + + const previousButton = this.shadowRoot.querySelector("[data-ui5-versioning-button=\"previous\"]"); + const nextButton = this.shadowRoot.querySelector("[data-ui5-versioning-button=\"next\"]"); + + if (!previousButton || !nextButton) { + return; + } + + const isPreviousDisabled = this.currentStep <= 1; + const isNextDisabled = this.currentStep === this.totalSteps; + const wasPreviousDisabled = this._previousCurrentStep <= 1; + const wasNextDisabled = this._previousCurrentStep === this._previousTotalSteps; + + if (isPreviousDisabled && !wasPreviousDisabled && !isNextDisabled && this._lastClickedButton === LastClickedButton.Previous && nextButton instanceof HTMLElement) { + nextButton.focus(); + this._lastClickedButton = LastClickedButton.None; + } else if (isNextDisabled && !wasNextDisabled && !isPreviousDisabled && this._lastClickedButton === LastClickedButton.Next && previousButton instanceof HTMLElement) { + previousButton.focus(); + this._lastClickedButton = LastClickedButton.None; + } + } + + handlePreviousVersionClick() { + this._lastClickedButton = LastClickedButton.Previous; + this.fireDecoratorEvent("version-change", { backwards: true }); + } + + handleNextVersionClick() { + this._lastClickedButton = LastClickedButton.Next; + this.fireDecoratorEvent("version-change", { backwards: false }); + } + + get _previousButtonAccessibleName() { + return Versioning.i18nBundle.getText(VERSIONING_PREVIOUS_BUTTON_TEXT); + } + + get _nextButtonAccessibleName() { + return Versioning.i18nBundle.getText(VERSIONING_NEXT_BUTTON_TEXT); + } + + get _previousButtonTooltip() { + return Versioning.i18nBundle.getText(VERSIONING_PREVIOUS_BUTTON_TOOLTIP); + } + + get _nextButtonTooltip() { + return Versioning.i18nBundle.getText(VERSIONING_NEXT_BUTTON_TOOLTIP); + } + + /** + * @override + */ + get classes() { + return { + root: { + ...super.classes.root, + "ui5-ai-versioning": true, + }, + }; + } +} + +Versioning.define(); + +export default Versioning; diff --git a/packages/ai/src/VersioningTemplate.tsx b/packages/ai/src/VersioningTemplate.tsx new file mode 100644 index 000000000000..8fad507b1bd0 --- /dev/null +++ b/packages/ai/src/VersioningTemplate.tsx @@ -0,0 +1,34 @@ +import type Versioning from "./Versioning.js"; +import ToolbarLabel from "./ToolbarLabel.js"; +import ToolbarButton from "@ui5/webcomponents/dist/ToolbarButton.js"; +import "@ui5/webcomponents-icons/dist/navigation-left-arrow.js"; +import "@ui5/webcomponents-icons/dist/navigation-right-arrow.js"; + +export default function VersioningTemplate(this: Versioning) { + return ( + <> + + + = this.totalSteps} + onClick={this.handleNextVersionClick} + data-ui5-versioning-button="next" + /> + + ); +} diff --git a/packages/ai/src/WritingAssistant.ts b/packages/ai/src/WritingAssistant.ts new file mode 100644 index 000000000000..0cef8bd13c90 --- /dev/null +++ b/packages/ai/src/WritingAssistant.ts @@ -0,0 +1,210 @@ +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/dist/UI5Element.js"; +import announce from "@ui5/webcomponents-base/dist/util/InvisibleMessage.js"; +import { getI18nBundle } from "@ui5/webcomponents-base/dist/i18nBundle.js"; +import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js"; +import { + WRITING_ASSISTANT_LABEL, + VERSIONING_PREVIOUS_BUTTON_TEXT, + VERSIONING_NEXT_BUTTON_TEXT, + WRITING_ASSISTANT_GENERATING_ANNOUNCEMENT, + WRITING_ASSISTANT_TOOLBAR_ACCESSIBLE_NAME, + WRITING_ASSISTANT_BUTTON_ACCESSIBLE_NAME, + WRITING_ASSISTANT_BUTTON_TOOLTIP, +} from "./generated/i18n/i18n-defaults.js"; + +// Styles +import WritingAssistantCss from "./generated/themes/WritingAssistant.css.js"; + +// Templates +import WritingAssistantTemplate from "./WritingAssistantTemplate.js"; +import Versioning from "./Versioning.js"; +import ToolbarLabel from "./ToolbarLabel.js"; + +// UI5 Components +import Toolbar from "@ui5/webcomponents/dist/Toolbar.js"; +import ToolbarSpacer from "@ui5/webcomponents/dist/ToolbarSpacer.js"; +import ToolbarButton from "@ui5/webcomponents/dist/ToolbarButton.js"; + +// Icons +import "@ui5/webcomponents-icons/dist/ai.js"; +import "@ui5/webcomponents-icons/dist/stop.js"; + +/** + * @class + * + * ### Overview + * + * The `ui5-ai-textarea-toolbar` component provides a specialized toolbar for AI TextArea functionality. + * It manages different states of the AI assistant and provides version navigation capabilities. + * + * ### Structure + * The `ui5-ai-textarea-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/ai/dist/WritingAssistant.js";` + * + * @constructor + * @extends UI5Element + * @since 1.0.0-rc.1 + * @private + */ +@customElement({ + tag: "ui5-ai-writing-assistant", + languageAware: true, + renderer: jsxRenderer, + template: WritingAssistantTemplate, + styles: [WritingAssistantCss], + dependencies: [ + Versioning, + ToolbarLabel, + Toolbar, + ToolbarSpacer, + ToolbarButton, + ], +}) + +/** + * Fired when the user clicks on version navigation buttons. + * + * @public + */ +@event("version-change") + +/** + * Fired when the user clicks on the AI button. + * + * @public + */ +@event("button-click") + +/** + * Fired when the user clicks on the "Stop" button to stop ongoing AI text generation. + * + * @public + */ +@event("stop-generation") + +class WritingAssistant extends UI5Element { + eventDetails!: { + "version-change": { + backwards: boolean; + }; + "button-click": { + clickTarget: HTMLElement; + }; + "stop-generation": object; + }; + + /** + * Defines whether the Writing Assistant is currently loading. + * + * When `true`, indicates that an AI action is in progress. + * + * @default false + */ + @property({ type: Boolean }) + loading = false; + + static i18nBundle: I18nBundle; + + static async onDefine() { + WritingAssistant.i18nBundle = await getI18nBundle("@ui5/webcomponents-ai"); + } + + /** + * Defines the action text of the AI Writing Assistant. + * + * This text is displayed in the toolbar to indicate the current or last + * performed AI action (e.g., "Generated text", "Simplified text"). + * + * @default "" + * @public + * @since 1.0.0-rc.1 + */ + @property() + actionText = ""; + + /** + * Indicates the index of the currently displayed result version. + * + * The index is **1-based** (i.e. `1` represents the first result). + * This property is synchronized with the parent AI TextArea component. + * + * @default 1 + * @public + * @since 1.0.0-rc.1 + */ + @property({ type: Number }) + currentVersionIndex = 1; + + /** + * Indicates the total number of result versions available. + * + * This property determines whether version navigation controls are displayed. + * When totalVersions > 1, previous/next buttons become available. + * + * @default 1 + * @public + * @since 1.0.0-rc.1 + */ + @property({ type: Number }) + totalVersions = 1; + + /** + * Handles the version change event from the versioning component. + */ + handleVersionChange(e: CustomEvent<{ backwards: boolean }>) { + this.fireDecoratorEvent("version-change", { backwards: e.detail.backwards }); + } + + /** + * Handles the click event for the AI generate button. + * Toggles between generate and stop states based on current button state. + * + * @public + */ + handleButtonClick(e: Event): void { + const target = e.target as HTMLElement & { dataset?: { state?: string } }; + if (target?.dataset?.state === "generating") { + this.fireDecoratorEvent("stop-generation"); + } else { + this.fireDecoratorEvent("button-click", { clickTarget: target }); + announce(WritingAssistant.i18nBundle.getText(WRITING_ASSISTANT_GENERATING_ANNOUNCEMENT), "Polite"); + } + } + get _ariaLabel() { + return WritingAssistant.i18nBundle.getText(WRITING_ASSISTANT_LABEL); + } + + get _previousButtonAccessibleName() { + return WritingAssistant.i18nBundle.getText(VERSIONING_PREVIOUS_BUTTON_TEXT); + } + + get _nextButtonAccessibleName() { + return WritingAssistant.i18nBundle.getText(VERSIONING_NEXT_BUTTON_TEXT); + } + + get _toolbarAccessibleName() { + return WritingAssistant.i18nBundle.getText(WRITING_ASSISTANT_TOOLBAR_ACCESSIBLE_NAME); + } + + get _buttonAccessibleName() { + return WritingAssistant.i18nBundle.getText(WRITING_ASSISTANT_BUTTON_ACCESSIBLE_NAME); + } + + get _buttonTooltip() { + return WritingAssistant.i18nBundle.getText(WRITING_ASSISTANT_BUTTON_TOOLTIP); + } +} + +WritingAssistant.define(); + +export default WritingAssistant; diff --git a/packages/ai/src/WritingAssistantTemplate.tsx b/packages/ai/src/WritingAssistantTemplate.tsx new file mode 100644 index 000000000000..33f79ec26c39 --- /dev/null +++ b/packages/ai/src/WritingAssistantTemplate.tsx @@ -0,0 +1,49 @@ +import type WritingAssistant from "./WritingAssistant.js"; +import Versioning from "./Versioning.js"; +import ToolbarLabel from "./ToolbarLabel.js"; + +import Toolbar from "@ui5/webcomponents/dist/Toolbar.js"; +import ToolbarSpacer from "@ui5/webcomponents/dist/ToolbarSpacer.js"; +import ToolbarButton from "@ui5/webcomponents/dist/ToolbarButton.js"; + +export default function WritingAssistantTemplate(this: WritingAssistant) { + const isMultiResults = this.totalVersions > 1; + const hasResults = (this.totalVersions > 0 && this.actionText) || this.loading; + + return ( + + {isMultiResults && !this.loading && ( + + )} + + {hasResults && this.actionText && ( + + )} + + + + + + ); +} diff --git a/packages/ai/src/bundle.esm.ts b/packages/ai/src/bundle.esm.ts index e66e9c14a4d1..158da279831e 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 "./TextArea.js"; export default testAssets; diff --git a/packages/ai/src/i18n/messagebundle.properties b/packages/ai/src/i18n/messagebundle.properties index 38cf9268d21f..a62c708da7e4 100644 --- a/packages/ai/src/i18n/messagebundle.properties +++ b/packages/ai/src/i18n/messagebundle.properties @@ -10,3 +10,29 @@ PROMPT_INPUT_CHARACTERS_EXCEEDED={0} characters over limit #XTXT: Text for BUTTON_TOOLTIP_TEXT={0} with Artificial Intelligence +#XFLD: Text for the aria-label attribute of the Writing Assistant toolbar +WRITING_ASSISTANT_LABEL=AI Writing Assistant Toolbar Shift + F4 + +#XFLD: Previous version button accessible name +VERSIONING_PREVIOUS_BUTTON_TEXT=Previous Version + +#XFLD: Next version button accessible name +VERSIONING_NEXT_BUTTON_TEXT=Next Version + +#XMSG: Announcement message when AI writing assistant starts generating +WRITING_ASSISTANT_GENERATING_ANNOUNCEMENT=Stop generating (ESC) + +#XFLD: Accessible name for the AI Writing Assistant toolbar +WRITING_ASSISTANT_TOOLBAR_ACCESSIBLE_NAME=AI Writing Assistant Toolbar + +#XFLD: Accessible name for the AI Writing Assistant button +WRITING_ASSISTANT_BUTTON_ACCESSIBLE_NAME=AI Writing Assistant + +#XFLD: Tooltip for the AI Writing Assistant button +WRITING_ASSISTANT_BUTTON_TOOLTIP=AI Writing Assistant (Shift + F4) + +#XFLD: Tooltip for the Previous Version button +VERSIONING_PREVIOUS_BUTTON_TOOLTIP=Previous Version + +#XFLD: Tooltip for the Next Version button +VERSIONING_NEXT_BUTTON_TOOLTIP=Next Version diff --git a/packages/ai/src/i18n/messagebundle_en.properties b/packages/ai/src/i18n/messagebundle_en.properties index fee1714bbb95..11017ff0a5c9 100644 --- a/packages/ai/src/i18n/messagebundle_en.properties +++ b/packages/ai/src/i18n/messagebundle_en.properties @@ -5,3 +5,4 @@ PROMPT_INPUT_CHARACTERS_EXCEEDED={0} characters over limit BUTTON_TOOLTIP_TEXT={0} with Artificial Intelligence +WRITING_ASSISTANT_GENERATING_ANNOUNCEMENT=AI writing assistant generating. Stop generating (ESC) diff --git a/packages/ai/src/themes/AITextArea.css b/packages/ai/src/themes/AITextArea.css new file mode 100644 index 000000000000..cf94e4a6daef --- /dev/null +++ b/packages/ai/src/themes/AITextArea.css @@ -0,0 +1,45 @@ +.ui5-ai-textarea-root { + position: relative; + display: flex; + flex-direction: column; + width: 100%; + height: 100%; +} + +.ui5-ai-textarea-root .ui5-textarea-wrapper { + display: flex; + flex-direction: column; + flex: 1; + position: relative; +} + +.ui5-ai-textarea-root [part="footer"] { + position: relative; + width: 100%; + margin-top: auto; + flex-shrink: 0; +} + +.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; + border-top: none; +} + +.ui5-ai-writing-assistant-footer-bar--with-border { + border-top: 1px solid var(--sapPageFooter_BorderColor); +} + +.ui5-ai-writing-assistant-action-label { + margin-left: 0.5rem; + color: var(--sapContent_LabelColor); + font-size: var(--sapFontSmallSize); +} + +#ai-menu-wrapper { + position: relative; + z-index: 1000; +} diff --git a/packages/ai/src/themes/Versioning.css b/packages/ai/src/themes/Versioning.css new file mode 100644 index 000000000000..f489e04bd71c --- /dev/null +++ b/packages/ai/src/themes/Versioning.css @@ -0,0 +1,20 @@ +:host { + display: inline-flex; + align-items: center; +} + +#versioning-history { + display: flex; + align-items: center; + gap: 0; +} + +.version-step-counter { + margin: 0 0.25rem; + display: flex; + align-items: center; + color: var(--sapContent_LabelColor); + font-size: var(--sapFontSmallSize); + line-height: 1; +} + diff --git a/packages/ai/src/themes/WritingAssistant.css b/packages/ai/src/themes/WritingAssistant.css new file mode 100644 index 000000000000..776c82717d5f --- /dev/null +++ b/packages/ai/src/themes/WritingAssistant.css @@ -0,0 +1,45 @@ +.ui5-ai-textarea-root { + position: relative; + display: flex; + flex-direction: column; + width: 100%; + height: 100%; +} + +.ui5-ai-textarea-root .ui5-textarea-wrapper { + display: flex; + flex-direction: column; + flex: 1; + position: relative; +} + +.ui5-ai-textarea-root [part="footer"] { + width: 100%; + margin-top: auto; + flex-shrink: 0; +} + +.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; + border-top: none; + padding: 0.5rem; + min-height: 2.75rem; + display: flex; + align-items: center; + justify-content: flex-start; +} + +.ui5-ai-writing-assistant-footer-bar--with-border { + border-top: 1px solid var(--sapPageFooter_BorderColor); +} + +.ui5-ai-writing-assistant-action-label { + color: var(--sapContent_LabelColor); + font-size: var(--sapFontSize); + font-family: "72override", var(--sapFontFamily); + margin-left: 0.25rem; +} \ No newline at end of file diff --git a/packages/ai/test/pages/TextArea.html b/packages/ai/test/pages/TextArea.html new file mode 100644 index 000000000000..9a62c58231c4 --- /dev/null +++ b/packages/ai/test/pages/TextArea.html @@ -0,0 +1,395 @@ + + + + + + + + + AI TextArea - Demo + + + + + + + + +
+

AI TextArea Component

+ + + + + + + + + + + + + + +
+ Reset + Toggle Readonly + Toggle Disabled +
+ +
+ AI TextArea Features:
+ • Click the AI button (⚡) to open the action menu
+ • During generation, the AI button becomes a stop button (⏹)
+ • Click stop button or press Escape to cancel generation
+ • Version navigation appears when multiple versions exist

+ + Keyboard Shortcuts:
+ • Shift + F4: Focus AI button
+ • Ctrl + Shift + Z: Previous version (when multiple versions exist)
+ • Ctrl + Shift + Y: Next version (when multiple versions exist)
+ • Escape: Stop generation (during loading)

+ + Generation Behavior:
+ • Selecting a new action while generating will stop current generation
+ • Stopped generations are saved as versions with "(stopped)" suffix
+ • History is limited to 50 versions to prevent memory issues +
+
+ + + + +