diff --git a/packages/main/cypress/specs/ExpandableText.cy.ts b/packages/main/cypress/specs/ExpandableText.cy.ts
new file mode 100644
index 000000000000..9a0303e3c926
--- /dev/null
+++ b/packages/main/cypress/specs/ExpandableText.cy.ts
@@ -0,0 +1,321 @@
+import { html } from "lit";
+import "../../src/ExpandableText.js";
+
+describe("ExpandableText", () => {
+ describe("Rendering and Interaction", () => {
+ it("Should display full text if maxCharacters is not set", () => {
+ const text = "This is a very long text that should be displayed";
+
+ cy.mount(html``);
+
+ cy.get("[ui5-expandable-text]")
+ .shadow()
+ .find("[ui5-text]")
+ .contains(text)
+ .should("exist");
+ });
+
+ it("Should display full text if maxCharacters are set, but not exceeded", () => {
+ const text = "This is a very long text that should be displayed";
+
+ cy.mount(html``);
+
+ cy.get("[ui5-expandable-text]")
+ .shadow()
+ .find("[ui5-text]")
+ .contains(text)
+ .should("exist");
+ });
+
+ it("Should display 'Show More' if maxCharacters are exceeded", () => {
+ const text = "This is a very long text that should be displayed";
+ const maxCharacters = 5;
+
+ cy.mount(html``);
+
+ cy.get("[ui5-expandable-text]").shadow().as("expTextShadow");
+
+ cy.get("@expTextShadow")
+ .find("[ui5-text]")
+ .contains(text.substring(0, maxCharacters))
+ .should("exist");
+
+ cy.get("@expTextShadow")
+ .find(".ui5-exp-text-ellipsis")
+ .contains("... ")
+ .should("exist");
+
+ cy.get("@expTextShadow")
+ .find("[ui5-link].ui5-exp-text-toggle")
+ .contains("Show More")
+ .should("exist");
+ });
+
+ it("Should display 'Show More' if maxCharacters are exceeded, set to 0", () => {
+ const text = "This is a very long text that should be displayed";
+
+ cy.mount(html``);
+
+ cy.get("[ui5-expandable-text]").shadow().as("expTextShadow");
+
+ cy.get("@expTextShadow")
+ .find("[ui5-text]")
+ .contains(/^$/)
+ .should("exist");
+
+ cy.get("@expTextShadow")
+ .find(".ui5-exp-text-ellipsis")
+ .should("exist");
+
+ cy.get("@expTextShadow")
+ .find("[ui5-link].ui5-exp-text-toggle")
+ .contains("Show More")
+ .should("exist");
+ });
+
+ it("Should NOT display 'Show More' if maxCharacters are 0, but text is empty", () => {
+ cy.mount(html``);
+
+ cy.get("[ui5-expandable-text]").shadow().as("expTextShadow");
+
+ cy.get("@expTextShadow")
+ .find("[ui5-text]")
+ .contains(/^$/)
+ .should("exist");
+
+ cy.get("@expTextShadow")
+ .find(".ui5-exp-text-ellipsis")
+ .should("not.exist");
+
+ cy.get("@expTextShadow")
+ .find("[ui5-link].ui5-exp-text-toggle")
+ .should("not.exist");
+ });
+
+ it("Toggling 'Show More' and 'Show Less'", () => {
+ const text = "This is a very long text that should be displayed";
+ const maxCharacters = 5;
+
+ cy.mount(html``);
+
+ cy.get("[ui5-expandable-text]").shadow().as("expTextShadow");
+ cy.get("@expTextShadow").find("[ui5-link].ui5-exp-text-toggle").as("toggle");
+
+ cy.get("@toggle")
+ .contains("Show More")
+ .realClick();
+
+ cy.get("@expTextShadow")
+ .find("[ui5-text]")
+ .contains(text)
+ .should("exist");
+
+ cy.get("@toggle")
+ .contains("Show Less")
+ .realClick();
+
+ cy.get("@expTextShadow")
+ .find("[ui5-text]")
+ .contains(text.substring(0, maxCharacters))
+ .should("exist");
+
+ cy.get("@toggle")
+ .contains("Show More")
+ .should("exist");
+ });
+
+ it("Toggling 'Show More' and 'Show Less' with keyboard", () => {
+ const text = "This is a very long text that should be displayed";
+ const maxCharacters = 5;
+
+ cy.mount(html`
+
+
+ `);
+
+ cy.get("[ui5-expandable-text]").shadow().as("expTextShadow");
+ cy.get("@expTextShadow").find("[ui5-link].ui5-exp-text-toggle").as("toggle");
+
+ cy.get("#before")
+ .focus();
+
+ cy.get("#before")
+ .realPress("Tab");
+
+ cy.get("@toggle")
+ .realPress("Enter");
+
+ cy.get("@expTextShadow")
+ .find("[ui5-text]")
+ .contains(text)
+ .should("exist");
+
+ cy.get("@toggle")
+ .contains("Show Less")
+ .realPress("Enter");
+
+ cy.get("@expTextShadow")
+ .find("[ui5-text]")
+ .contains(text.substring(0, maxCharacters))
+ .should("exist");
+
+ cy.get("@toggle")
+ .contains("Show More")
+ .should("exist");
+ });
+ });
+
+ describe("Empty Indicator", () => {
+ it("Should display empty indicator if text is empty and emptyIndicatorMode=On", () => {
+ cy.mount(html``);
+
+ cy.get("[ui5-expandable-text]")
+ .shadow()
+ .find("[ui5-text]")
+ .should("have.attr", "empty-indicator-mode", "On");
+ });
+
+ it("Should NOT display empty indicator if text is empty and emptyIndicatorMode=Off", () => {
+ cy.mount(html``);
+
+ cy.get("[ui5-expandable-text]")
+ .shadow()
+ .find("[ui5-text]")
+ .should("have.attr", "empty-indicator-mode", "Off");
+ });
+ });
+
+ describe("Rendering and Interaction with overflowMode=Popover", () => {
+ it("Toggling 'Show More' and 'Show Less'", () => {
+ const text = "This is a very long text that should be displayed";
+ const maxCharacters = 5;
+
+ cy.mount(html``);
+
+ cy.get("[ui5-expandable-text]").shadow().as("expTextShadow");
+ cy.get("@expTextShadow").find("[ui5-link].ui5-exp-text-toggle").as("toggle");
+
+ cy.get("@expTextShadow")
+ .find("[ui5-text]")
+ .contains(text.substring(0, maxCharacters))
+ .should("exist");
+
+ cy.get("@expTextShadow")
+ .find(".ui5-exp-text-ellipsis")
+ .contains("... ")
+ .should("exist");
+
+ cy.get("@toggle")
+ .contains("Show More")
+ .realClick();
+
+ cy.get("@toggle")
+ .invoke("attr", "id")
+ .as("expectedOpenerId");
+
+ cy.get("@expTextShadow")
+ .find("[ui5-responsive-popover]")
+ .as("rpo");
+
+ cy.get("@rpo")
+ .should("exist")
+ .should("have.attr", "open");
+
+ cy.get("@rpo")
+ .should("have.attr", "content-only-on-desktop");
+
+ cy.get("@rpo")
+ .invoke("attr", "opener")
+ .then(function testOpenerId(opener) {
+ expect(opener).to.equal(this.expectedOpenerId);
+ });
+
+ cy.get("@toggle")
+ .contains("Show Less")
+ .realClick();
+
+ cy.get("@expTextShadow")
+ .find("[ui5-responsive-popover]")
+ .should("not.have.attr", "open");
+ });
+
+ it("Toggling 'Show More' and 'Show Less' with keyboard", () => {
+ const text = "This is a very long text that should be displayed";
+ const maxCharacters = 5;
+
+ cy.mount(html`
+
+
+ `);
+
+ cy.get("[ui5-expandable-text]").shadow().as("expTextShadow");
+ cy.get("@expTextShadow").find("[ui5-link].ui5-exp-text-toggle").as("toggle");
+
+ cy.get("#before")
+ .focus();
+
+ cy.get("#before")
+ .realPress("Tab");
+
+ cy.get("@toggle")
+ .realPress("Enter");
+
+ cy.get("@expTextShadow")
+ .find("[ui5-responsive-popover]")
+ .as("rpo");
+
+ cy.get("@rpo")
+ .should("exist")
+ .should("have.attr", "open");
+
+ cy.get("@toggle")
+ .contains("Show Less")
+ .should("exist");
+
+ cy.realPress("Escape");
+
+ cy.get("@rpo")
+ .should("not.have.attr", "open");
+
+ cy.get("@toggle")
+ .contains("Show More")
+ .should("exist");
+ });
+
+ it("Toggling 'Show More' and 'Show Less' on Mobile Device", () => {
+ const text = "This is a very long text that should be displayed";
+ const maxCharacters = 5;
+
+ cy.mount(html``);
+ cy.ui5SimulateDevice("phone");
+
+ cy.get("[ui5-expandable-text]").shadow().as("expTextShadow");
+
+ cy.get("@expTextShadow")
+ .find("[ui5-link].ui5-exp-text-toggle")
+ .contains("Show More")
+ .realClick();
+
+ cy.get("@expTextShadow")
+ .find("[ui5-responsive-popover]").as("rpo");
+
+ cy.get("@rpo")
+ .should("exist")
+ .should("have.attr", "open");
+
+ cy.get("@rpo")
+ .should("have.attr", "_hide-header");
+
+ cy.get("@rpo")
+ .contains("[slot=footer] [ui5-button]", "Close")
+ .should("exist");
+
+ cy.get("@rpo")
+ .contains("[slot=footer] [ui5-button]", "Close")
+ .realClick();
+
+ cy.get("@rpo")
+ .should("not.have.attr", "open");
+ });
+ });
+});
diff --git a/packages/main/src/ExpandableText.hbs b/packages/main/src/ExpandableText.hbs
new file mode 100644
index 000000000000..80853697c09c
--- /dev/null
+++ b/packages/main/src/ExpandableText.hbs
@@ -0,0 +1,42 @@
+
+
+ {{_displayedText}}
+
+
+ {{#if _maxCharactersExceeded}}
+ {{_ellipsisText}}
+
+ {{_textForToggle}}
+
+
+ {{#if _usePopover}}
+
+ {{text}}
+
+
+ {{/if}}
+ {{/if}}
+
\ No newline at end of file
diff --git a/packages/main/src/ExpandableText.ts b/packages/main/src/ExpandableText.ts
new file mode 100644
index 000000000000..463283aff21a
--- /dev/null
+++ b/packages/main/src/ExpandableText.ts
@@ -0,0 +1,177 @@
+import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js";
+import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js";
+import property from "@ui5/webcomponents-base/dist/decorators/property.js";
+import i18n from "@ui5/webcomponents-base/dist/decorators/i18n.js";
+import litRender from "@ui5/webcomponents-base/dist/renderer/LitRenderer.js";
+import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js";
+import { isPhone } from "@ui5/webcomponents-base/dist/Device.js";
+import Text from "./Text.js";
+import Link from "./Link.js";
+import ResponsivePopover from "./ResponsivePopover.js";
+import Button from "./Button.js";
+import ExpandableTextOverflowMode from "./types/ExpandableTextOverflowMode.js";
+import type TextEmptyIndicatorMode from "./types/TextEmptyIndicatorMode.js";
+import {
+ EXPANDABLE_TEXT_SHOW_LESS,
+ EXPANDABLE_TEXT_SHOW_MORE,
+ EXPANDABLE_TEXT_CLOSE,
+} from "./generated/i18n/i18n-defaults.js";
+
+// Template
+import ExpandableTextTemplate from "./generated/templates/ExpandableTextTemplate.lit.js";
+
+// Styles
+import ExpandableTextCss from "./generated/themes/ExpandableText.css.js";
+
+/**
+ * @class
+ *
+ * ### Overview
+ *
+ * The `ui5-expandable-text` component allows displaying a large body of text in a small space. It provides an "expand" functionality, which shows the full text.
+ *
+ * ### Usage
+ *
+ * #### When to use:
+ * - You specifically have to deal with long texts
+ *
+ * #### When not to use:
+ * - Do not use long texts and descriptions if you can provide short and meaningful alternatives
+ * - The content is critical for the user. In this case use short descriptions that can fit in
+ *
+ * ### Responsive Behavior
+ *
+ * On phones, when the component is set to show the full text in a popover, the popover will be displayed full screen.
+ *
+ * ### ES6 Module Import
+ *
+ * `import "@ui5/webcomponents/dist/Text";`
+ *
+ * @constructor
+ * @extends UI5Element
+ * @public
+ * @since 2.5.0
+ */
+@customElement({
+ tag: "ui5-expandable-text",
+ renderer: litRender,
+ styles: ExpandableTextCss,
+ template: ExpandableTextTemplate,
+ dependencies: [
+ Text,
+ Link,
+ ResponsivePopover,
+ Button,
+ ],
+})
+class ExpandableText extends UI5Element {
+ /**
+ * Text of the component.
+ *
+ * @default ""
+ * @public
+ */
+ @property()
+ text?: string;
+
+ /**
+ * Maximum number of characters to be displayed. After them, a "show more" trigger will be displayed.
+ * @default Infinity
+ * @public
+ */
+ @property({ type: Number })
+ maxCharacters : number = Infinity;
+
+ /**
+ * Determines how the full text will be displayed.
+ * @default "InPlace"
+ * @public
+ */
+ @property()
+ overflowMode: `${ExpandableTextOverflowMode}` = ExpandableTextOverflowMode.InPlace
+
+ /**
+ * Specifies if an empty indicator should be displayed when there is no text.
+ * @default "Off"
+ * @public
+ */
+ @property()
+ emptyIndicatorMode: `${TextEmptyIndicatorMode}` = "Off";
+
+ @property({ type: Boolean })
+ _expanded = false;
+
+ @i18n("@ui5/webcomponents")
+ static i18nBundle: I18nBundle;
+
+ _preventNextToggleClickHandling = false;
+
+ getFocusDomRef(): HTMLElement | undefined {
+ if (this._usePopover) {
+ return this.shadowRoot?.querySelector("[ui5-responsive-popover]") as HTMLElement;
+ }
+
+ return this.shadowRoot?.querySelector("ui5-link") as HTMLElement;
+ }
+
+ get _displayedText() {
+ if (this._expanded && !this._usePopover) {
+ return this.text;
+ }
+
+ return this.text?.substring(0, this.maxCharacters);
+ }
+
+ get _maxCharactersExceeded() {
+ return (this.text?.length || 0) > this.maxCharacters;
+ }
+
+ get _usePopover() {
+ return this.overflowMode === ExpandableTextOverflowMode.Popover;
+ }
+
+ get _ellipsisText() {
+ if (this._expanded && !this._usePopover) {
+ return " ";
+ }
+
+ return "... ";
+ }
+
+ get _textForToggle() {
+ return this._expanded ? ExpandableText.i18nBundle.getText(EXPANDABLE_TEXT_SHOW_LESS) : ExpandableText.i18nBundle.getText(EXPANDABLE_TEXT_SHOW_MORE);
+ }
+
+ get _closeButtonText() {
+ return ExpandableText.i18nBundle.getText(EXPANDABLE_TEXT_CLOSE);
+ }
+
+ _handlePopoverClose() {
+ if (!isPhone()) {
+ this._expanded = false;
+
+ // TODO: find way to prevent next click handling, only if the popover is closed by a click on the toggle (link)
+ // if (this.shadowRoot!.activeElement === this.shadowRoot!.querySelector("[ui5-link]")) {
+ // this._preventNextToggleClickHandling = true;
+ // }
+ }
+ }
+
+ _handleToggleClick() {
+ // if (this._preventNextToggleClickHandling) {
+ // this._preventNextToggleClickHandling = false;
+ // return;
+ // }
+
+ this._expanded = !this._expanded;
+ }
+
+ _handleCloseButtonClick(e: CustomEvent) {
+ this._expanded = false;
+ e.stopPropagation();
+ }
+}
+
+ExpandableText.define();
+
+export default ExpandableText;
diff --git a/packages/main/src/Popover.ts b/packages/main/src/Popover.ts
index 223baf30f13d..fbaf876fd73d 100644
--- a/packages/main/src/Popover.ts
+++ b/packages/main/src/Popover.ts
@@ -303,7 +303,7 @@ class Popover extends Popup {
}
const rootNode = this.getRootNode();
- const openerHTMLElement = rootNode instanceof Document ? rootNode.getElementById(opener) : document.getElementById(opener);
+ const openerHTMLElement = rootNode instanceof DocumentFragment ? rootNode.getElementById(opener) : document.getElementById(opener);
if (openerHTMLElement && this._isUI5Element(openerHTMLElement)) {
return openerHTMLElement.getFocusDomRef();
diff --git a/packages/main/src/bundle.esm.ts b/packages/main/src/bundle.esm.ts
index 8f67245661ac..ab4c8fbe015e 100644
--- a/packages/main/src/bundle.esm.ts
+++ b/packages/main/src/bundle.esm.ts
@@ -47,6 +47,7 @@ import DatePicker from "./DatePicker.js";
import DateRangePicker from "./DateRangePicker.js";
import DateTimePicker from "./DateTimePicker.js";
import Dialog from "./Dialog.js";
+import ExpandableText from "./ExpandableText.js";
import Form from "./Form.js";
import FormItem from "./FormItem.js";
import FormGroup from "./FormGroup.js";
diff --git a/packages/main/src/i18n/messagebundle.properties b/packages/main/src/i18n/messagebundle.properties
index 6035281c047b..abe14382da7e 100644
--- a/packages/main/src/i18n/messagebundle.properties
+++ b/packages/main/src/i18n/messagebundle.properties
@@ -170,6 +170,15 @@ EMPTY_INDICATOR_SYMBOL=\u2013
#XFLD: ARIA announcement for the empty value.
EMPTY_INDICATOR_ACCESSIBLE_TEXT=Empty value
+#XLNK: Link to allow the user to see complete text
+EXPANDABLE_TEXT_SHOW_MORE=Show More
+
+#XLNK: This link allows the user to collapse the complete text and display only the first characters that can fit
+EXPANDABLE_TEXT_SHOW_LESS=Show Less
+
+#XBUT: Text for close action
+EXPANDABLE_TEXT_CLOSE=Close
+
FILEUPLOAD_BROWSE=Browse...
#XACT: File uploader title
diff --git a/packages/main/src/themes/ExpandableText.css b/packages/main/src/themes/ExpandableText.css
new file mode 100644
index 000000000000..e289e1482556
--- /dev/null
+++ b/packages/main/src/themes/ExpandableText.css
@@ -0,0 +1,32 @@
+:host {
+ display: inline-block;
+ font-family: var(--sapFontFamily);
+ font-size: var(--sapFontSize);
+ color: var(--sapTextColor);
+}
+
+:host([hidden]) {
+ display: none;
+}
+
+.ui5-exp-text-text {
+ display: inline;
+}
+
+.ui5-exp-text-text,
+.ui5-exp-text-toggle {
+ font-family: inherit;
+ font-size: inherit;
+}
+
+.ui5-exp-text-text,
+.ui5-exp-text-ellipsis {
+ color: inherit;
+}
+
+.ui5-exp-text-footer {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+}
\ No newline at end of file
diff --git a/packages/main/src/types/ExpandableTextOverflowMode.ts b/packages/main/src/types/ExpandableTextOverflowMode.ts
new file mode 100644
index 000000000000..9dbd6e6f907b
--- /dev/null
+++ b/packages/main/src/types/ExpandableTextOverflowMode.ts
@@ -0,0 +1,19 @@
+/**
+ * Overflow Mode.
+ * @public
+ */
+enum ExpandableTextOverflowMode {
+ /**
+ * Overflowing text is appended in-place.
+ * @public
+ */
+ InPlace = "InPlace",
+
+ /**
+ * Full text is displayed in a popover.
+ * @public
+ */
+ Popover = "Popover",
+}
+
+export default ExpandableTextOverflowMode;
diff --git a/packages/main/test/pages/ExpandableText.html b/packages/main/test/pages/ExpandableText.html
new file mode 100644
index 000000000000..005c084209d5
--- /dev/null
+++ b/packages/main/test/pages/ExpandableText.html
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+ ExpandableText
+
+
+
+
+ ExpandableText
+ General
+ Two Texts Next to Each Other
+
+
+
+ No "max-characters" Set
+
+
+ max-characters=150
+
+
+
+ max-characters=9999
+
+
+ max-characters=0
+
+
+ overflowMode=Popover
+
+
+ EmptyIndicatorMode
+ On
+
+
+ On, with Text
+
+
+ Off
+
+
+ Off, with Text
+
+
+
diff --git a/packages/website/docs/_components_pages/main/ExpandableText.mdx b/packages/website/docs/_components_pages/main/ExpandableText.mdx
new file mode 100644
index 000000000000..66585ad4808a
--- /dev/null
+++ b/packages/website/docs/_components_pages/main/ExpandableText.mdx
@@ -0,0 +1,14 @@
+---
+slug: ../ExpandableText
+sidebar_class_name: newComponentBadge
+---
+
+import Basic from "../../_samples/main/ExpandableText/Basic/Basic.md";
+
+
+<%COMPONENT_OVERVIEW%>
+
+## Basic Sample
+
+
+<%COMPONENT_METADATA%>
diff --git a/packages/website/docs/_samples/main/ExpandableText/Basic/Basic.md b/packages/website/docs/_samples/main/ExpandableText/Basic/Basic.md
new file mode 100644
index 000000000000..17798ecc59ab
--- /dev/null
+++ b/packages/website/docs/_samples/main/ExpandableText/Basic/Basic.md
@@ -0,0 +1,4 @@
+import html from '!!raw-loader!./sample.html';
+import js from '!!raw-loader!./main.js';
+
+
diff --git a/packages/website/docs/_samples/main/ExpandableText/Basic/main.js b/packages/website/docs/_samples/main/ExpandableText/Basic/main.js
new file mode 100644
index 000000000000..effaaeb913d5
--- /dev/null
+++ b/packages/website/docs/_samples/main/ExpandableText/Basic/main.js
@@ -0,0 +1 @@
+import "@ui5/webcomponents/dist/ExpandableText.js";
\ No newline at end of file
diff --git a/packages/website/docs/_samples/main/ExpandableText/Basic/sample.html b/packages/website/docs/_samples/main/ExpandableText/Basic/sample.html
new file mode 100644
index 000000000000..aab56007e365
--- /dev/null
+++ b/packages/website/docs/_samples/main/ExpandableText/Basic/sample.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+ Sample
+
+
+
+
+
+ Lorem ipsum dolor sit, amet consectetur adipisicing elit. Perferendis accusamus assumenda debitis excepturi distinctio adipisci magnam qui a id, praesentium ullam voluptatem ad, modi quo perspiciatis soluta quasi facere molestiae
+
+
+
+
+
+