diff --git a/packages/fiori/cypress/specs/ShellBar.cy.ts b/packages/fiori/cypress/specs/ShellBar.cy.ts index bf41f841d16a..1131898c6f4b 100644 --- a/packages/fiori/cypress/specs/ShellBar.cy.ts +++ b/packages/fiori/cypress/specs/ShellBar.cy.ts @@ -1,7 +1,10 @@ import { html } from "lit"; import "../../src/ShellBar.js"; import "@ui5/webcomponents-icons/dist/activities.js"; +import "@ui5/webcomponents-icons/dist/sys-help.js"; import "@ui5/webcomponents-icons/dist/da.js"; +import "@ui5/webcomponents-fiori/dist/ShellBarItem.js"; +import "@ui5/webcomponents/dist/ToggleButton.js"; describe("Responsiveness", () => { const basicTemplate = html` @@ -25,14 +28,14 @@ describe("Responsiveness", () => { - +
Instructions
- PR2 + PR2 `; const templateWithMenuItems = html` @@ -60,17 +63,11 @@ describe("Responsiveness", () => { - +
Instructions
- - Application 1 - Application 2 - Application 3 - Application 4 - Application 5 `; const templateWithOnlyOneAction = html` { .find(".ui5-shellbar-button-product-switch") .as("productSwitchIcon"); + cy.get("@shellbar") + .find("ui5-toggle-button[slot='assistant']") + .as("assistant"); + cy.get("@backButton").should("be.visible"); cy.get("@primaryTitle").should("be.visible"); cy.get("@secondaryTitle").should("be.visible"); @@ -142,6 +143,7 @@ describe("Responsiveness", () => { cy.get("@notificationsIcon").should("be.visible"); cy.get("@profileIcon").should("be.visible"); cy.get("@productSwitchIcon").should("be.visible"); + cy.get("@assistant").should("be.visible"); cy.get("@searchButton").click(); diff --git a/packages/fiori/src/DynamicPage.hbs b/packages/fiori/src/DynamicPage.hbs index c037ba01571b..847eb676f92d 100644 --- a/packages/fiori/src/DynamicPage.hbs +++ b/packages/fiori/src/DynamicPage.hbs @@ -5,7 +5,6 @@ id="{{_id}}-header" aria-label="{{_headerLabel}}" aria-expanded="{{_headerExpanded}}" - role="region" @ui5-_toggle-title={{onToggleTitle}} > @@ -17,7 +16,7 @@ {{> header-actions}} {{/if}} - + {{#if headerInContent}} {{/if}} diff --git a/packages/fiori/src/DynamicPageHeader.hbs b/packages/fiori/src/DynamicPageHeader.hbs index ea608438ca76..ed67ab7843cb 100644 --- a/packages/fiori/src/DynamicPageHeader.hbs +++ b/packages/fiori/src/DynamicPageHeader.hbs @@ -1,3 +1,3 @@ -
+
diff --git a/packages/fiori/src/ShellBar.ts b/packages/fiori/src/ShellBar.ts index 1cf3b16c27ba..5520bce25e5f 100644 --- a/packages/fiori/src/ShellBar.ts +++ b/packages/fiori/src/ShellBar.ts @@ -258,13 +258,7 @@ const PREDEFINED_PLACE_ACTIONS = ["feedback", "sys-help"]; * @param {Array} array of all the items that disappeared from additional context slot * @public */ -@event("additional-context-disappears", { - detail: { - /** - * @public - */ - items: { type: Array }, - }, +@event("additional-context-disappears", { bubbles: true, cancelable: true, }) @@ -277,6 +271,7 @@ class ShellBar extends UI5Element { "logo-click": ShellBarLogoClickEventDetail, "menu-item-click": ShellBarMenuItemClickEventDetail, "search-button-click": ShellBarSearchButtonEventDetail, + "additional-context-disappears": ShellBarAdditionalContextItemDisappearsEventDetail, } /** * Defines the `primaryTitle`. @@ -517,6 +512,7 @@ class ShellBar extends UI5Element { _skipLayout = false; _lastOffsetWidth = 0; _lessSearchSpace = false; + _searchButtonInteraction = false; _headerPress: () => void; @@ -573,6 +569,10 @@ class ShellBar extends UI5Element { if (this._lastOffsetWidth !== this.offsetWidth) { this._overflowActions(); this._searchBarInitialState(); + if (this._searchButtonInteraction && this._showSearchField) { + this._showSearchField = false; + this._searchButtonInteraction = false; + } } }, RESIZE_THROTTLE_RATE); } @@ -872,7 +872,7 @@ class ShellBar extends UI5Element { _handleActionsOverflow() { const itemsToOverflow = this.itemsToOverflow; const container = this.shadowRoot!.querySelector(".ui5-shellbar-overflow-container-right")!; - const searchFieldWidth = this.searchField[0].offsetWidth; + const searchFieldWidth = this.searchField[0] ? this.searchField[0].offsetWidth : 0; const nonDisappearingItems = Array.from(container.querySelectorAll(".ui5-shellbar-no-overflow-button")); const nonDisappearingItemsWidth = nonDisappearingItems.reduce((acc, el) => acc + el.offsetWidth + this.domCalculatedValues("--_ui5-shellbar-overflow-button-margin"), 0); const totalWidth = container.offsetWidth - nonDisappearingItemsWidth - this.separatorsWidth - searchFieldWidth; @@ -920,7 +920,7 @@ class ShellBar extends UI5Element { this._hideAdditionalContext(); if (this.additionalCoontextHidden && JSON.stringify(this.additionalCoontextHidden) !== JSON.stringify(this._cachedHiddenContent)) { - this.fireDecoratorEvent("additional-context-disappears", { items: this.additionalCoontextHidden }); + this.fireDecoratorEvent("additional-context-disappears", { items: this.additionalCoontextHidden }); } this._cachedHiddenContent = this.additionalCoontextHidden; @@ -995,6 +995,7 @@ class ShellBar extends UI5Element { input.focus(); } }, 100); + this._searchButtonInteraction = true; } async _handleActionListClick() { @@ -1044,6 +1045,7 @@ class ShellBar extends UI5Element { _handleCancelButtonPress() { this._showSearchField = false; + this._searchButtonInteraction = false; } _handleProductSwitchPress(e: MouseEvent) { diff --git a/packages/fiori/test/specs/DynamicPage.spec.js b/packages/fiori/test/specs/DynamicPage.spec.js index 0f70c5b6380f..e3fa40b14c57 100644 --- a/packages/fiori/test/specs/DynamicPage.spec.js +++ b/packages/fiori/test/specs/DynamicPage.spec.js @@ -406,6 +406,7 @@ describe("ARIA attributes", () => { const title = await browser.$("#page ui5-dynamic-page-title"); const titleFocusArea = await title.shadow$(".ui5-dynamic-page-title-focus-area"); const headerWrapper = await page.shadow$(".ui5-dynamic-page-title-header-wrapper"); + const headerRoot = await page.$("ui5-dynamic-page-header").shadow$(".ui5-dynamic-page-header-root"); const headerActions = await page.shadow$("ui5-dynamic-page-header-actions"); const expandButton = await headerActions.shadow$("ui5-button.ui5-dynamic-page-header-action-expand"); const pinButton = await headerActions.shadow$("ui5-toggle-button.ui5-dynamic-page-header-action-pin"); @@ -418,7 +419,7 @@ describe("ARIA attributes", () => { "aria-label value is correct"); assert.strictEqual(await headerWrapper.getAttribute("aria-expanded"), "true", "aria-expanded value is correct"); - assert.strictEqual(await headerWrapper.getAttribute("role"), "region", + assert.strictEqual(await headerRoot.getAttribute("role"), "region", "header role is correct"); assert.strictEqual(await titleFocusArea.getAttribute("aria-expanded"), "true", @@ -453,8 +454,6 @@ describe("ARIA attributes", () => { "aria-label value is correct"); assert.strictEqual(await headerWrapper.getAttribute("aria-expanded"), "false", "aria-expanded value is correct"); - assert.strictEqual(await headerWrapper.getAttribute("role"), "region", - "role is correct"); assert.strictEqual(await titleFocusArea.getAttribute("aria-expanded"), "false", "aria-expanded value is correct"); diff --git a/packages/main/cypress/specs/ExpandableText.cy.ts b/packages/main/cypress/specs/ExpandableText.cy.ts new file mode 100644 index 000000000000..445f132a3053 --- /dev/null +++ b/packages/main/cypress/specs/ExpandableText.cy.ts @@ -0,0 +1,389 @@ +import { html } from "lit"; +import "../../src/ExpandableText.js"; +import { + EXPANDABLE_TEXT_SHOW_MORE, + EXPANDABLE_TEXT_SHOW_LESS, + EXPANDABLE_TEXT_CLOSE, +} from "../../src/generated/i18n/i18n-defaults.js"; + +describe("ExpandableText", () => { + describe("Rendering and Interaction", () => { + it("Should display only 100 characters by default", () => { + const text = "This is a very long text that should be displayed. This is a very long text that should be displayed. This is a very long text that should be displayed."; + + cy.mount(html``); + + expect(text.length).to.be.greaterThan(100); + + cy.get("[ui5-expandable-text]") + .shadow() + .find("[ui5-text]") + .contains(text.substring(0, 100)) + .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 set and 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-exp-text-toggle") + .contains(EXPANDABLE_TEXT_SHOW_MORE.defaultText) + .should("exist") + .should("have.attr", "ui5-link"); + }); + + 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-exp-text-toggle") + .contains(EXPANDABLE_TEXT_SHOW_MORE.defaultText) + .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-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-exp-text-toggle").as("toggle"); + + cy.get("@toggle") + .contains(EXPANDABLE_TEXT_SHOW_MORE.defaultText) + .realClick(); + + cy.get("@expTextShadow") + .find("[ui5-text]") + .contains(text) + .should("exist"); + + cy.get("@toggle") + .contains(EXPANDABLE_TEXT_SHOW_LESS.defaultText) + .realClick(); + + cy.get("@expTextShadow") + .find("[ui5-text]") + .contains(text.substring(0, maxCharacters)) + .should("exist"); + + cy.get("@toggle") + .contains(EXPANDABLE_TEXT_SHOW_MORE.defaultText) + .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-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(EXPANDABLE_TEXT_SHOW_LESS.defaultText) + .realPress("Enter"); + + cy.get("@expTextShadow") + .find("[ui5-text]") + .contains(text.substring(0, maxCharacters)) + .should("exist"); + + cy.get("@toggle") + .contains(EXPANDABLE_TEXT_SHOW_MORE.defaultText) + .should("exist"); + }); + + it("ARIA attributes", () => { + 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-exp-text-toggle").as("toggle"); + + cy.get("@toggle") + .should("have.attr", "accessible-role", "Button"); + + cy.get("@toggle") + .invoke("prop", "accessibilityAttributes") + .should("deep.equal", { + expanded: false, + }); + + cy.get("@toggle") + .realClick(); + + cy.get("@toggle") + .invoke("prop", "accessibilityAttributes") + .should("deep.equal", { + expanded: true, + }); + }); + }); + + 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-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(EXPANDABLE_TEXT_SHOW_MORE.defaultText) + .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(EXPANDABLE_TEXT_SHOW_LESS.defaultText) + .realClick(); + + cy.get("@expTextShadow") + .find("[ui5-responsive-popover]") + .should("not.have.attr", "open"); + }); + + it("ARIA attributes", () => { + 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-exp-text-toggle").as("toggle"); + + cy.get("@toggle") + .should("have.attr", "accessible-name"); + + cy.get("@toggle") + .invoke("prop", "accessibilityAttributes") + .should("deep.equal", { + expanded: false, + hasPopup: "dialog", + }); + + cy.get("@expTextShadow") + .find("[ui5-responsive-popover]") + .should("have.attr", "accessible-name-ref", "popover-text"); + + cy.get("@toggle") + .realClick(); + + cy.get("@toggle") + .invoke("prop", "accessibilityAttributes") + .should("deep.equal", { + expanded: true, + hasPopup: "dialog", + }); + }); + + 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-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(EXPANDABLE_TEXT_SHOW_LESS.defaultText) + .should("exist"); + + cy.realPress("Escape"); + + cy.get("@rpo") + .should("not.have.attr", "open"); + + cy.get("@toggle") + .contains(EXPANDABLE_TEXT_SHOW_MORE.defaultText) + .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-exp-text-toggle") + .contains(EXPANDABLE_TEXT_SHOW_MORE.defaultText) + .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]", EXPANDABLE_TEXT_CLOSE.defaultText) + .should("exist"); + + cy.get("@rpo") + .contains("[slot=footer] [ui5-button]", EXPANDABLE_TEXT_CLOSE.defaultText) + .realClick(); + + cy.get("@rpo") + .should("not.have.attr", "open"); + }); + }); +}); diff --git a/packages/main/cypress/specs/StepInput.cy.ts b/packages/main/cypress/specs/StepInput.cy.ts new file mode 100644 index 000000000000..e829301e0d2f --- /dev/null +++ b/packages/main/cypress/specs/StepInput.cy.ts @@ -0,0 +1,34 @@ +import { html } from "lit"; +import "../../src/StepInput.js"; +import type StepInput from "../../src/StepInput.js"; + +describe("StepInput Tests", () => { + it("tets input event prevention", () => { + cy.mount(html` + + `); + + cy.get("[ui5-step-input]") + .as("stepInput"); + + cy.get("@stepInput") + .then($input => { + $input.get(0).addEventListener("input", e => { + e.preventDefault(); + (e.target as StepInput).value = 30; + }); + }); + + cy.get("@stepInput") + .realClick(); + + cy.realPress("1"); + + cy.get("@stepInput") + .shadow() + .find("ui5-input") + .shadow() + .find("input") + .should("have.value", "30"); + }); +}); diff --git a/packages/main/cypress/specs/Toast.cy.ts b/packages/main/cypress/specs/Toast.cy.ts new file mode 100644 index 000000000000..f38efd1763ef --- /dev/null +++ b/packages/main/cypress/specs/Toast.cy.ts @@ -0,0 +1,43 @@ +import { html } from "lit"; +import "../../src/Toast.js"; +import "../../src/Button.js"; +import "../../src/List.js"; +import type Toast from "../../src/Toast.js"; + +describe("Toast - test popover API", () => { + it("Should verify the toast has the popover attribute set to manual", () => { + cy.mount(html` + TopStart`); + cy.get("[ui5-toast]") + .should("have.attr", "popover", "manual") + .should("be.visible"); + }); + + it("Toast should stay on top of list after scroll", () => { + cy.mount(html` + TopStart + + List Item 1 + List Item 2 + List Item 3 + `); + + cy.get("[ui5-toast]") + .should("have.attr", "popover", "manual") + .should("be.visible"); + + cy.get("#toast") + .then($toast => { + const toastRect = $toast[0].getBoundingClientRect(); + cy.get("#list") + .then($list => { + const listRect = $list[0].getBoundingClientRect(); + const isOverlapping = toastRect.right > listRect.left + && toastRect.left < listRect.right + && toastRect.bottom > listRect.top + && toastRect.top < listRect.bottom; + expect(isOverlapping).to.be.true; + }); + }); + }); +}); diff --git a/packages/main/cypress/specs/base/Events.cy.ts b/packages/main/cypress/specs/base/Events.cy.ts index 18ba5d84c8b4..0fc3061613f8 100644 --- a/packages/main/cypress/specs/base/Events.cy.ts +++ b/packages/main/cypress/specs/base/Events.cy.ts @@ -130,13 +130,13 @@ describe("Event bubbling", () => { World Hello - + Open Menu - + @@ -175,6 +175,11 @@ describe("Event bubbling", () => { dialog.get(0).addEventListener("ui5-close", cy.stub().as("dialogClosed")); }); + cy.get("@dialog") + .then(dialog => { + dialog.get(0).addEventListener("ui5-open", cy.stub().as("dialogOpened")); + }); + cy.get("@select") .then(select => { select.get(0).addEventListener("ui5-close", cy.stub().as("selClosed")); @@ -190,6 +195,11 @@ describe("Event bubbling", () => { multiCombobox.get(0).addEventListener("ui5-close", cy.stub().as("mcbClosed")); }); + cy.get("@multiCombobox") + .then(multiCombobox => { + multiCombobox.get(0).addEventListener("open", cy.stub().as("mcbOpened")); + }); + cy.get("@dialog").invoke("attr", "open", true); // act - open and close Select @@ -209,6 +219,10 @@ describe("Event bubbling", () => { .find("[ui5-mcb-item]") .should("be.visible"); + // assert - the open event of the MultiComboBox do not bubble + cy.get("@mcbOpened").should("have.been.calledOnce"); + cy.get("@dialogOpened").should("have.been.calledTwice"); + cy.get("@multiComboboxIcon") .realClick(); diff --git a/packages/main/src/Breadcrumbs.ts b/packages/main/src/Breadcrumbs.ts index de8230181396..7c30971e5cd0 100644 --- a/packages/main/src/Breadcrumbs.ts +++ b/packages/main/src/Breadcrumbs.ts @@ -28,6 +28,7 @@ import { BREADCRUMBS_ARIA_LABEL, BREADCRUMBS_OVERFLOW_ARIA_LABEL, BREADCRUMBS_CANCEL_BUTTON, + FORM_SELECTABLE_AVALIABLE_VALUES, } from "./generated/i18n/i18n-defaults.js"; import Link from "./Link.js"; import type { LinkClickEventDetail } from "./Link.js"; @@ -261,6 +262,14 @@ class Breadcrumbs extends UI5Element { return items; } + /** + * Returns the translatable accessible name for the popover + * @private + */ + get _accessibleNamePopover() { + return Breadcrumbs.i18nBundle.getText(FORM_SELECTABLE_AVALIABLE_VALUES); + } + _onfocusin(e: FocusEvent) { const target = e.target, labelWrapper = this.getCurrentLocationLabelWrapper(), diff --git a/packages/main/src/BreadcrumbsPopover.hbs b/packages/main/src/BreadcrumbsPopover.hbs index 6651a1c41f5f..44cace0c4b55 100644 --- a/packages/main/src/BreadcrumbsPopover.hbs +++ b/packages/main/src/BreadcrumbsPopover.hbs @@ -5,7 +5,8 @@ placement="Bottom" horizontal-align="Start" _hide-header - @keydown="{{_onkeydown}}"> + @keydown="{{_onkeydown}}" + accessible-name={{_accessibleNamePopover}}> diff --git a/packages/main/src/ExpandableText.hbs b/packages/main/src/ExpandableText.hbs new file mode 100644 index 000000000000..5e5b4329d564 --- /dev/null +++ b/packages/main/src/ExpandableText.hbs @@ -0,0 +1,48 @@ +
+ + {{_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..6217ea5d021a --- /dev/null +++ b/packages/main/src/ExpandableText.ts @@ -0,0 +1,196 @@ +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, { type LinkAccessibilityAttributes } 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, + EXPANDABLE_TEXT_SHOW_LESS_POPOVER_ARIA_LABEL, + EXPANDABLE_TEXT_SHOW_MORE_POPOVER_ARIA_LABEL, +} 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/collapse" functionality, which shows/hides potentially truncated text. + * + * ### Usage + * + * #### When to use: + * - To accommodate long texts in limited space, for example in list items, table cell texts, or forms + * + * #### When not to use: + * - The content is critical for the user. In this case use short descriptions that can fit in + * - Strive to provide short and meaningful texts to avoid excessive number of "Show More" links on the page + * + * ### Responsive Behavior + * + * On phones, if the component is configured to display the full text in a popover, the popover will appear in full screen. + * + * ### ES6 Module Import + * + * `import "@ui5/webcomponents/dist/ExpandableText";` + * + * @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 initially. If the text length exceeds this limit, the text will be truncated with an ellipsis, and the "More" link will be displayed. + * @default 100 + * @public + */ + @property({ type: Number }) + maxCharacters = 100; + + /** + * 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; + + 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); + } + + get _accessibilityAttributesForToggle(): LinkAccessibilityAttributes { + if (this._usePopover) { + return { + expanded: this._expanded, + hasPopup: "dialog", + }; + } + + return { + expanded: this._expanded, + }; + } + + get _accessibleNameForToggle() { + if (this._usePopover) { + return this._expanded ? ExpandableText.i18nBundle.getText(EXPANDABLE_TEXT_SHOW_LESS_POPOVER_ARIA_LABEL) : ExpandableText.i18nBundle.getText(EXPANDABLE_TEXT_SHOW_MORE_POPOVER_ARIA_LABEL); + } + + return null; + } + + _handlePopoverClose() { + if (!isPhone()) { + this._expanded = false; + } + } + + _handleToggleClick() { + this._expanded = !this._expanded; + } + + _handleToggleMousedown(e: MouseEvent) { + if (this.shadowRoot!.querySelector("[ui5-responsive-popover]")?.open) { + // Workaround for PopoverRegistry handler that closes the popover on mousedown, + // resulting in "click" event with wrong _expanded state + e.stopPropagation(); + } + } + + _handleCloseButtonClick(e: CustomEvent) { + this._expanded = false; + e.stopPropagation(); + } +} + +ExpandableText.define(); + +export default ExpandableText; diff --git a/packages/main/src/MultiComboBox.ts b/packages/main/src/MultiComboBox.ts index eec8ee715095..0b7196dadf16 100644 --- a/packages/main/src/MultiComboBox.ts +++ b/packages/main/src/MultiComboBox.ts @@ -226,9 +226,7 @@ type MultiComboboxItemWithSelection = { * @since 2.0.0 * @public */ -@event("open", { - bubbles: true, -}) +@event("open") /** * Fired when the dropdown is closed. diff --git a/packages/main/src/StepInput.hbs b/packages/main/src/StepInput.hbs index 458071bf91f0..2a80c3a8bcb6 100644 --- a/packages/main/src/StepInput.hbs +++ b/packages/main/src/StepInput.hbs @@ -46,6 +46,7 @@ @ui5-change="{{_onInputChange}}" @focusout="{{_onInputFocusOut}}" @focusin="{{_onInputFocusIn}}" + @ui5-input="{{_onInput}}" > {{#if valueStateMessage.length}} diff --git a/packages/main/src/StepInput.ts b/packages/main/src/StepInput.ts index fe182f720832..1d5c24c334ed 100644 --- a/packages/main/src/StepInput.ts +++ b/packages/main/src/StepInput.ts @@ -30,7 +30,7 @@ import "@ui5/webcomponents-icons/dist/less.js"; import "@ui5/webcomponents-icons/dist/add.js"; import Icon from "./Icon.js"; -import Input from "./Input.js"; +import Input, { type InputEventDetail } from "./Input.js"; import InputType from "./types/InputType.js"; // Styles @@ -106,6 +106,15 @@ type StepInputValueStateChangeEventDetail = { @event("change", { bubbles: true, }) +/** + * Fired when the value of the component changes at each keystroke. + * @public + * @since 2.6.0 + */ +@event("input", { + cancelable: true, + bubbles: true, +}) /** * Fired before the value state of the component is updated internally. * The event is preventable, meaning that if it's default action is @@ -122,6 +131,7 @@ type StepInputValueStateChangeEventDetail = { class StepInput extends UI5Element implements IFormInputElement { eventDetails!: { change: void + input: InputEventDetail "value-state-change": StepInputValueStateChangeEventDetail } /** @@ -379,6 +389,14 @@ class StepInput extends UI5Element implements IFormInputElement { }, 0); } + _onInput(e: CustomEvent) { + const prevented = !this.fireDecoratorEvent("input", { inputType: e.detail.inputType }); + + if (prevented) { + e.preventDefault(); + } + } + _onInputFocusIn() { this._inputFocused = true; if (this.value !== this._previousValue) { diff --git a/packages/main/src/Toast.ts b/packages/main/src/Toast.ts index cb6e515ebe1b..2e36d7bf0025 100644 --- a/packages/main/src/Toast.ts +++ b/packages/main/src/Toast.ts @@ -190,6 +190,15 @@ class Toast extends UI5Element { } } + onAfterRendering() { + if (!this.hasAttribute("popover")) { + this.setAttribute("popover", "manual"); + } + if (this.open) { + this.showPopover(); + } + } + _onfocusin() { if (this.focusable) { this.focused = true; @@ -217,6 +226,7 @@ class Toast extends UI5Element { this.focusable = false; this.focused = false; this.fireDecoratorEvent("close"); + this.hidePopover(); } _onmouseover() { diff --git a/packages/main/src/bundle.esm.ts b/packages/main/src/bundle.esm.ts index 2546061c0488..1306e3420dbd 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..78348583bc48 100644 --- a/packages/main/src/i18n/messagebundle.properties +++ b/packages/main/src/i18n/messagebundle.properties @@ -61,6 +61,9 @@ BREADCRUMBS_OVERFLOW_ARIA_LABEL=More #XFLD: Breadcrumbs popover cancel button BREADCRUMBS_CANCEL_BUTTON=Cancel +#XACT: Breadcrumbs popover accessible name +FORM_SELECTABLE_AVALIABLE_VALUES=Available Values + #XTOL: text that could be show if BusyIndicator is active BUSY_INDICATOR_TITLE=Please wait @@ -170,6 +173,21 @@ 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: Link to allow 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 of dialog on mobile devices +EXPANDABLE_TEXT_CLOSE=Close + +#XACT: ARIA-label text for link that allows the user to see complete text in a popover +EXPANDABLE_TEXT_SHOW_MORE_POPOVER_ARIA_LABEL=Show the full text + +#XACT: ARIA-label text for link that allows the user to close the popover with the complete text +EXPANDABLE_TEXT_SHOW_LESS_POPOVER_ARIA_LABEL=Close the popover + 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..063074bd6054 --- /dev/null +++ b/packages/main/src/themes/ExpandableText.css @@ -0,0 +1,36 @@ +: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-popover::part(content) { + padding-inline: 1rem; +} + +.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/themes/Toast.css b/packages/main/src/themes/Toast.css index 4117ac441102..9079e4993984 100644 --- a/packages/main/src/themes/Toast.css +++ b/packages/main/src/themes/Toast.css @@ -17,6 +17,9 @@ text-overflow: ellipsis; white-space: pre-line; padding: 1rem; + inset: unset; + margin: 0; + border: none; } .ui5-toast-root { 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..b462c0147c88 --- /dev/null +++ b/packages/main/test/pages/ExpandableText.html @@ -0,0 +1,52 @@ + + + + + + + ExpandableText + + + + +

ExpandableText

+

General

+

Two Texts Next to Each Other

+ + + +

No "max-characters" Set

+ + +

max-characters=150

+ +

+ +

max-characters=9999

+ + +

max-characters=0

+ + +

max-characters=-1

+ + +

overflowMode=Popover

+ + +

EmptyIndicatorMode

+

On

+ + +

On, with Text

+ + +

Off

+ + +

Off, with Text

+ + +

RTL

+ + diff --git a/packages/main/test/pages/StepInput.html b/packages/main/test/pages/StepInput.html index 9fea8da71ee2..549a47df5589 100644 --- a/packages/main/test/pages/StepInput.html +++ b/packages/main/test/pages/StepInput.html @@ -160,18 +160,24 @@

StepInput change event test

>
+

'input' event prevented

+ +

'change' event result

diff --git a/packages/main/test/specs/Breadcrumbs.spec.js b/packages/main/test/specs/Breadcrumbs.spec.js index f219540adafe..af37c9e8133d 100644 --- a/packages/main/test/specs/Breadcrumbs.spec.js +++ b/packages/main/test/specs/Breadcrumbs.spec.js @@ -218,6 +218,17 @@ describe("Breadcrumbs general interaction", () => { assert.strictEqual(await link.getProperty("accessibleName"), expectedAccessibleName, "label for last link is correct"); }); + it("renders accessible name of popover", async () => { + await browser.url(`test/pages/Breadcrumbs.html`); + + const externalElement = (await browser.$("#breadcrumbsWithAccName").shadow$$("ui5-link"))[3]; + const popover = await browser.$(`#breadcrumbs1`).shadow$("ui5-responsive-popover"); + const expectedAriaLabel = "Available values"; + + await externalElement.click(); + assert.ok(await popover.shadow$(".ui5-popover-root").getProperty("ariaLabel"), expectedAriaLabel); + }); + it("cancels default if item-click event listener calls preventDefault", async () => { const breadcrumbs = await browser.$("#breadcrumbsPreventDefault"), link = (await breadcrumbs.shadow$$("ui5-link"))[1]; 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..653de071c86d --- /dev/null +++ b/packages/website/docs/_components_pages/main/ExpandableText.mdx @@ -0,0 +1,20 @@ +--- +slug: ../ExpandableText +sidebar_class_name: newComponentBadge +--- + +import Basic from "../../_samples/main/ExpandableText/Basic/Basic.md"; +import OverflowModePopover from "../../_samples/main/ExpandableText/OverflowModePopover/OverflowModePopover.md"; + +<%COMPONENT_OVERVIEW%> + +## Basic Sample + + +<%COMPONENT_METADATA%> + +## More Samples + +### Overflow Mode Popover + + 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..831e55653b0b --- /dev/null +++ b/packages/website/docs/_samples/main/ExpandableText/Basic/main.js @@ -0,0 +1,5 @@ +import "@ui5/webcomponents/dist/ExpandableText.js"; +import "@ui5/webcomponents/dist/Table.js"; +import "@ui5/webcomponents/dist/TableHeaderRow.js"; +import "@ui5/webcomponents/dist/TableHeaderCell.js"; +import "@ui5/webcomponents/dist/Label.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..434b44a44909 --- /dev/null +++ b/packages/website/docs/_samples/main/ExpandableText/Basic/sample.html @@ -0,0 +1,45 @@ + + + + + + + + Sample + + + + + + + + Product + Description + Dimensions + Price + + + Notebook Basic 15 + + + + 30 x 18 x 3 cm + 956 EUR + + + Notebook Basic 17 + + + + 29 x 17 x 3.1 cm + 1249 EUR + + + + + + + + \ No newline at end of file diff --git a/packages/website/docs/_samples/main/ExpandableText/OverflowModePopover/OverflowModePopover.md b/packages/website/docs/_samples/main/ExpandableText/OverflowModePopover/OverflowModePopover.md new file mode 100644 index 000000000000..17798ecc59ab --- /dev/null +++ b/packages/website/docs/_samples/main/ExpandableText/OverflowModePopover/OverflowModePopover.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/OverflowModePopover/main.js b/packages/website/docs/_samples/main/ExpandableText/OverflowModePopover/main.js new file mode 100644 index 000000000000..831e55653b0b --- /dev/null +++ b/packages/website/docs/_samples/main/ExpandableText/OverflowModePopover/main.js @@ -0,0 +1,5 @@ +import "@ui5/webcomponents/dist/ExpandableText.js"; +import "@ui5/webcomponents/dist/Table.js"; +import "@ui5/webcomponents/dist/TableHeaderRow.js"; +import "@ui5/webcomponents/dist/TableHeaderCell.js"; +import "@ui5/webcomponents/dist/Label.js"; \ No newline at end of file diff --git a/packages/website/docs/_samples/main/ExpandableText/OverflowModePopover/sample.html b/packages/website/docs/_samples/main/ExpandableText/OverflowModePopover/sample.html new file mode 100644 index 000000000000..e8fdef63c501 --- /dev/null +++ b/packages/website/docs/_samples/main/ExpandableText/OverflowModePopover/sample.html @@ -0,0 +1,46 @@ + + + + + + + + Sample + + + + + + + Product + Description + Dimensions + Price + + + Notebook Basic 15 + + + + 30 x 18 x 3 cm + 956 EUR + + + Notebook Basic 17 + + + + 29 x 17 x 3.1 cm + 1249 EUR + + + + + + + +