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 + + + + + +