diff --git a/packages/fiori/cypress/specs/IllustratedMessage.cy.tsx b/packages/fiori/cypress/specs/IllustratedMessage.cy.tsx index b89a2107509f0..41724802b2a75 100644 --- a/packages/fiori/cypress/specs/IllustratedMessage.cy.tsx +++ b/packages/fiori/cypress/specs/IllustratedMessage.cy.tsx @@ -2,6 +2,7 @@ import IllustratedMessage from "../../src/IllustratedMessage.js"; import Label from "@ui5/webcomponents/dist/Label.js"; import "@ui5/webcomponents-fiori/dist/illustrations/AllIllustrations.js" import Panel from "@ui5/webcomponents/dist/Panel.js"; +import { setTheme } from "@ui5/webcomponents-base/dist/config/Theme.js"; describe("Accessibility", () => { it("should add aria-hidden and role=presetation to the SVG when decorative is true", () => { @@ -156,73 +157,92 @@ describe("IllustratedMessage 'design' property", () => { }); }); -describe("Vertical responsiveness", () => { - it("content with auto design shrinks to fit the parent container", () => { - const expectedMedia = "dialog"; - +describe("Width-based responsiveness (auto height)", () => { + it("media is base when container width is 100px", () => { cy.mount( -
+
); cy.get("[ui5-illustrated-message]") - .shadow() - .find(".ui5-illustrated-message-root") - .should(($contents) => { - const scrollHeight = $contents[0].scrollHeight; - const offsetHeight = $contents[0].offsetHeight; + .should("have.attr", "media", IllustratedMessage.MEDIA.BASE); + }); - expect(scrollHeight).to.be.lessThan(300); - expect(scrollHeight).to.equal(offsetHeight); - }); + it("media is dot when container width is 200px", () => { + cy.mount( +
+ +
+ ); cy.get("[ui5-illustrated-message]") - .should("have.prop", "media", expectedMedia); + .should("have.attr", "media", IllustratedMessage.MEDIA.DOT); }); - it("content with auto design expands to fit the parent container", () => { - const expectedMedia = "scene"; + it("media is spot when container width is 300px", () => { + cy.mount( +
+ +
+ ); + cy.get("[ui5-illustrated-message]") + .should("have.attr", "media", IllustratedMessage.MEDIA.SPOT); + }); + + it("media is dialog when container width is 500px", () => { cy.mount( -
+
); cy.get("[ui5-illustrated-message]") - .shadow() - .find(".ui5-illustrated-message-root") - .should(($contents) => { - const scrollHeight = $contents[0].scrollHeight; - const offsetHeight = $contents[0].offsetHeight; - expect(scrollHeight).to.be.lessThan(500); - expect(scrollHeight).to.equal(offsetHeight); - }); - + .should("have.attr", "media", IllustratedMessage.MEDIA.DIALOG); + }); + + it("media is scene when container width is 800px", () => { + cy.mount( +
+ +
+ ); + cy.get("[ui5-illustrated-message]") - .should("have.prop", "media", expectedMedia); + .should("have.attr", "media", IllustratedMessage.MEDIA.SCENE); }); - it("content with fixed design fits the parent container", () => { + it("media updates from dot to scene when container width expands", () => { + // This test guards against a specific bug: without the `scrollHeight > clientHeight` + // guard in _checkHeightConstraints, _contentHeightForMedia["scene"] gets recorded + // while scene is rendered at full width. When width then shrinks (dot media), the + // container height (auto) shrinks too. On re-expansion, _mediaExceedsContainerHeight("scene") + // compares the stored scene height against the current (small) clientHeight and wrongly + // blocks the upgrade back to scene. cy.mount( -
- +
+
); - cy.get("div") - .invoke("css", "height", "250px"); + // First render at wide width so _contentHeightForMedia["scene"] gets populated + cy.get("[ui5-illustrated-message]") + .should("have.attr", "media", IllustratedMessage.MEDIA.SCENE); + + // Shrink to dot — clientHeight (auto) now equals the small dot illustration height + cy.get("#resizable-container") + .invoke("css", "width", "200px"); cy.get("[ui5-illustrated-message]") - .shadow() - .find(".ui5-illustrated-message-root") - .should(($contents) => { - const scrollHeight = $contents[0].scrollHeight; - const offsetHeight = $contents[0].offsetHeight; - expect(scrollHeight).to.be.lessThan(250); - expect(scrollHeight).to.equal(offsetHeight); - }); + .should("have.attr", "media", IllustratedMessage.MEDIA.DOT); + + // Expand back — must resolve to scene, not stay stuck at dot/dialog + cy.get("#resizable-container") + .invoke("css", "width", "800px"); + + cy.get("[ui5-illustrated-message]") + .should("have.attr", "media", IllustratedMessage.MEDIA.SCENE); }); it("shows image with unconstrained height when container has auto height", () => { @@ -240,8 +260,88 @@ describe("Vertical responsiveness", () => { .find(".ui5-illustrated-message-illustration svg") .should("have.css", "height", "160px"); }); +}); + +describe("Height-based responsiveness (restricted height, wide container)", () => { + after(() => { + // Restore theme so that a switch performed inside this describe does not bleed into + // subsequent specs. Mirrors the pattern in IllustratedMessageV5.cy.tsx. + cy.wrap({ setTheme }).invoke("setTheme", "sap_horizon"); + }); + + it("media is scene when container height is 600px", () => { + cy.mount( +
+ +
+ ); + + cy.get("[ui5-illustrated-message]") + .should("have.attr", "media", IllustratedMessage.MEDIA.SCENE); + + cy.get("[ui5-illustrated-message]") + .shadow() + .find(".ui5-illustrated-message-inner") + .should(($el) => { + expect($el[0].scrollHeight).to.be.lte($el[0].offsetHeight + 1); + }); + }); + + it("media shrinks to dialog when container height is 350px", () => { + cy.mount( +
+ +
+ ); + + cy.get("[ui5-illustrated-message]") + .should("have.attr", "media", IllustratedMessage.MEDIA.DIALOG); + + cy.get("[ui5-illustrated-message]") + .shadow() + .find(".ui5-illustrated-message-inner") + .should(($el) => { + expect($el[0].scrollHeight).to.be.lte($el[0].offsetHeight + 1); + }); + }); + + it("media shrinks to spot when container height is 250px", () => { + cy.mount( +
+ +
+ ); + + cy.get("[ui5-illustrated-message]") + .should("have.attr", "media", IllustratedMessage.MEDIA.SPOT); + + cy.get("[ui5-illustrated-message]") + .shadow() + .find(".ui5-illustrated-message-inner") + .should(($el) => { + expect($el[0].scrollHeight).to.be.lte($el[0].offsetHeight + 1); + }); + }); + + it("media shrinks to dot when container height is 180px", () => { + cy.mount( +
+ +
+ ); + + cy.get("[ui5-illustrated-message]") + .should("have.attr", "media", IllustratedMessage.MEDIA.DOT); + + cy.get("[ui5-illustrated-message]") + .shadow() + .find(".ui5-illustrated-message-inner") + .should(($el) => { + expect($el[0].scrollHeight).to.be.lte($el[0].offsetHeight + 1); + }); + }); - it("Illustration visible, when container fit content height", () => { + it("illustration is visible when container height fits content", () => { cy.mount(
@@ -257,7 +357,7 @@ describe("Vertical responsiveness", () => { }); }); - it("Illustration visible, when IM slotted and container has fixed height", () => { + it("illustration is visible when IM is slotted and container has fixed height", () => { cy.mount( @@ -272,6 +372,98 @@ describe("Vertical responsiveness", () => { expect(scrollHeight).to.not.equal(0); }); }); + + it("clears the content-height cache on theme change so media re-evaluates freshly", () => { + cy.mount( +
+ +
+ ); + + // Baseline: scene fits comfortably in 800×600. + cy.get("[ui5-illustrated-message]") + .should("have.attr", "media", IllustratedMessage.MEDIA.SCENE); + + // Poison the cache with an impossibly large scene content-height. On the next render without a + // cache clear, `_applyMedia` would treat scene as exceeding the container and downgrade. + cy.get("[ui5-illustrated-message]").then(($el) => { + const im = $el[0] as unknown as IllustratedMessage; + im._contentHeightForMedia[IllustratedMessage.MEDIA.SCENE] = 99999; + }); + + // Fire a theme change. The fix installs an `attachThemeLoaded` listener that clears the + // cache before the framework's themeAware re-render measures against the new theme. + cy.wrap({ setTheme }).invoke("setTheme", "sap_fiori_3"); + + // If the cache was cleared, the poisoned entry is gone and scene stays selected. + // Without the fix, the stale entry blocks scene and media downgrades to dialog. + cy.get("[ui5-illustrated-message]") + .should("have.attr", "media", IllustratedMessage.MEDIA.SCENE); + + cy.get("[ui5-illustrated-message]").then(($el) => { + const im = $el[0] as unknown as IllustratedMessage; + // The cache was cleared and then re-populated only if the (fresh) content actually + // overflows the container. In 800×600 nothing overflows, so no scene entry should + // remain from the poisoned value. + expect(im._contentHeightForMedia[IllustratedMessage.MEDIA.SCENE]).to.be.undefined; + }); + }); +}); + +describe("Height-based responsiveness (restricted height, dialog-width container)", () => { + it("media is dialog when container height is 500px", () => { + cy.mount( +
+ +
+ ); + + cy.get("[ui5-illustrated-message]") + .should("have.attr", "media", IllustratedMessage.MEDIA.DIALOG); + + cy.get("[ui5-illustrated-message]") + .shadow() + .find(".ui5-illustrated-message-inner") + .should(($el) => { + expect($el[0].scrollHeight).to.be.lte($el[0].offsetHeight + 1); + }); + }); + + it("media shrinks to spot when container height is 250px", () => { + cy.mount( +
+ +
+ ); + + cy.get("[ui5-illustrated-message]") + .should("have.attr", "media", IllustratedMessage.MEDIA.SPOT); + + cy.get("[ui5-illustrated-message]") + .shadow() + .find(".ui5-illustrated-message-inner") + .should(($el) => { + expect($el[0].scrollHeight).to.be.lte($el[0].offsetHeight + 1); + }); + }); + + it("media shrinks to dot when container height is 180px", () => { + cy.mount( +
+ +
+ ); + + cy.get("[ui5-illustrated-message]") + .should("have.attr", "media", IllustratedMessage.MEDIA.DOT); + + cy.get("[ui5-illustrated-message]") + .shadow() + .find(".ui5-illustrated-message-inner") + .should(($el) => { + expect($el[0].scrollHeight).to.be.lte($el[0].offsetHeight + 1); + }); + }); }); describe("Dot design resource handling", () => { diff --git a/packages/fiori/src/IllustratedMessage.ts b/packages/fiori/src/IllustratedMessage.ts index 73652773cd9d4..5c99af7613e33 100644 --- a/packages/fiori/src/IllustratedMessage.ts +++ b/packages/fiori/src/IllustratedMessage.ts @@ -1,11 +1,12 @@ import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js"; -import type { Slot, DefaultSlot } from "@ui5/webcomponents-base/dist/UI5Element.js"; +import type { Slot, DefaultSlot, ChangeInfo } 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 slot from "@ui5/webcomponents-base/dist/decorators/slot-strict.js"; import i18n from "@ui5/webcomponents-base/dist/decorators/i18n.js"; import ResizeHandler from "@ui5/webcomponents-base/dist/delegate/ResizeHandler.js"; import type { ResizeObserverCallback } from "@ui5/webcomponents-base/dist/delegate/ResizeHandler.js"; +import { attachThemeLoaded, detachThemeLoaded } from "@ui5/webcomponents-base/dist/Theming.js"; import { getIllustrationDataSync, getIllustrationData } from "@ui5/webcomponents-base/dist/asset-registries/Illustrations.js"; import { getEffectiveAriaLabelText } from "@ui5/webcomponents-base/dist/util/AccessibilityTextsHelper.js"; import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js"; @@ -275,20 +276,21 @@ class IllustratedMessage extends UI5Element { @i18n("@ui5/webcomponents-fiori") static i18nBundle: I18nBundle; - _lastKnownOffsetWidthForMedia: Record; - _lastKnownOffsetHeightForMedia: Record; - _lastKnownMedia: string; + _contentHeightForMedia: Record; _handleResize: ResizeObserverCallback; + _handleThemeLoaded: () => void; constructor() { super(); this._handleResize = this.handleResize.bind(this); - // this will store the last known offsetWidth of the IllustratedMessage DOM node for a given media (e.g. "Spot") - this._lastKnownOffsetWidthForMedia = {}; - this._lastKnownOffsetHeightForMedia = {}; - // this will store the last known media, in order to detect if IllustratedMessage has been hidden by expand/collapse container - this._lastKnownMedia = "base"; + this._handleThemeLoaded = () => { + // Cached content-height are theme-dependent, so clear them when the theme changes. + // This hook is needed because `onInvalidation` does not fire when the theme changes + this._contentHeightForMedia = {}; + }; + // this will store the height of the inner content of the IllustratedMessage (illustration + title + subtitle + actions) for a given media (e.g. "Spot") + this._contentHeightForMedia = {}; } static get BREAKPOINTS() { @@ -300,15 +302,6 @@ class IllustratedMessage extends UI5Element { }; } - static get BREAKPOINTS_HEIGHT() { - return { - DIALOG: 415, - SPOT: 284, - DOT: 207, - BASE: 61, - }; - } - static get MEDIA() { return { BASE: "base", @@ -372,54 +365,79 @@ class IllustratedMessage extends UI5Element { onEnterDOM() { ResizeHandler.register(this, this._handleResize); + attachThemeLoaded(this._handleThemeLoaded); } onExitDOM() { ResizeHandler.deregister(this, this._handleResize); + detachThemeLoaded(this._handleThemeLoaded); + } + + onInvalidation(changeInfo: ChangeInfo) { + if ( + (changeInfo.type === "property" && ["name", "titleText", "subtitleText"].includes(changeInfo.name)) + || (changeInfo.type === "slot" && ["title", "subtitle", "default"].includes(changeInfo.name)) + ) { + this._contentHeightForMedia = {}; + } } handleResize() { - if (this.design !== IllustrationMessageDesign.Auto) { - this._adjustHeightToFitContainer(); - return; + if (this.design === IllustrationMessageDesign.Auto) { + this._checkHeightConstraints(); + this._applyMedia(); } + } - this._applyMedia(); - window.requestAnimationFrame(this._adjustHeightToFitContainer.bind(this)); + /** + * Checks if the current height of the component is enough to display the illustration, title, subtitle and actions. + * If not, the minimum required height for the current media is stored in the `_contentHeightForMedia` object. + * @private + * @since 1.5.0 + */ + _checkHeightConstraints() { + // The `scrollHeight > clientHeight` guard is load-bearing: the cache must be populated ONLY + // when the content genuinely overflows the container. When the host container has + // `height: auto`, its clientHeight equals the content height and there is by definition no + // real constraint — recording that height would falsely poison `_contentHeightForMedia` and + // cause spurious downgrades on the next render (e.g. after a width shrink-and-grow). + if (this.media && this.scrollHeight > this.clientHeight) { // needs vertical responsiveness + const innerEl = this.shadowRoot!.querySelector(".ui5-illustrated-message-inner"); + const innerElHeight = innerEl ? innerEl.scrollHeight : 0; + innerElHeight && (this._contentHeightForMedia[this.media] = innerElHeight); + } } - _applyMedia(heightChange?: boolean) { - const currOffsetWidth = this.offsetWidth, - currOffsetHeight = this.offsetHeight; - - const design = heightChange ? currOffsetHeight : currOffsetWidth, - oBreakpounts = heightChange ? IllustratedMessage.BREAKPOINTS_HEIGHT : IllustratedMessage.BREAKPOINTS; - let newMedia = ""; - - if (design <= oBreakpounts.BASE) { - newMedia = IllustratedMessage.MEDIA.BASE; - } else if (design <= oBreakpounts.DOT) { - newMedia = IllustratedMessage.MEDIA.DOT; - } else if (design <= oBreakpounts.SPOT) { - newMedia = IllustratedMessage.MEDIA.SPOT; - } else if (design <= oBreakpounts.DIALOG) { - newMedia = IllustratedMessage.MEDIA.DIALOG; + _applyMedia() { + const width = this.offsetWidth; + let media = "", + mediaIndex = -1; + + if (width <= IllustratedMessage.BREAKPOINTS.BASE) { + media = IllustratedMessage.MEDIA.BASE; + } else if (width <= IllustratedMessage.BREAKPOINTS.DOT) { + media = IllustratedMessage.MEDIA.DOT; + } else if (width <= IllustratedMessage.BREAKPOINTS.SPOT) { + media = IllustratedMessage.MEDIA.SPOT; + } else if (width <= IllustratedMessage.BREAKPOINTS.DIALOG) { + media = IllustratedMessage.MEDIA.DIALOG; } else { - newMedia = IllustratedMessage.MEDIA.SCENE; + media = IllustratedMessage.MEDIA.SCENE; } - const lastKnownOffsetWidth = this._lastKnownOffsetWidthForMedia[newMedia], - lastKnownOffsetHeight = this._lastKnownOffsetHeightForMedia[newMedia]; - // prevents infinite resizing, when same width is detected for the same media, - // excluding the case in which, the control is placed inside expand/collapse container - if (!(lastKnownOffsetWidth && currOffsetWidth === lastKnownOffsetWidth - && lastKnownOffsetHeight && currOffsetHeight === lastKnownOffsetHeight) - || this._lastKnownOffsetWidthForMedia[this._lastKnownMedia] === 0 - || this._lastKnownOffsetHeightForMedia[this._lastKnownMedia] === 0) { - this.media = newMedia; - this._lastKnownOffsetWidthForMedia[newMedia] = currOffsetWidth; - this._lastKnownOffsetHeightForMedia[newMedia] = currOffsetHeight; - this._lastKnownMedia = newMedia; + + mediaIndex = Object.values(IllustratedMessage.MEDIA).indexOf(media); + + while (mediaIndex > 0 && this._mediaExceedsContainerHeight(media)) { + mediaIndex--; + media = Object.values(IllustratedMessage.MEDIA)[mediaIndex]; } + + this.media = media; + } + + _mediaExceedsContainerHeight(media: string): boolean { + const containerHeight = this.clientHeight || 0; + return !!this._contentHeightForMedia[media] && containerHeight < this._contentHeightForMedia[media]; } _setSVGAccAttrs() { @@ -446,21 +464,12 @@ class IllustratedMessage extends UI5Element { } } - _adjustHeightToFitContainer() { - const illustrationWrapper = this.shadowRoot!.querySelector(".ui5-illustrated-message-illustration"), - illustration = illustrationWrapper.querySelector("svg"); - - if (illustration) { - illustrationWrapper.classList.toggle("ui5-illustrated-message-illustration-fit-content", false); - if (this.getDomRef()!.scrollHeight > this.getDomRef()!.offsetHeight) { - illustrationWrapper.classList.toggle("ui5-illustrated-message-illustration-fit-content", true); - this._applyMedia(true /* height change */); - } - } - } - onAfterRendering() { this._setSVGAccAttrs(); + if (this.design === IllustrationMessageDesign.Auto) { + this._checkHeightConstraints(); + this._applyMedia(); + } } /** diff --git a/packages/fiori/src/themes/IllustratedMessage.css b/packages/fiori/src/themes/IllustratedMessage.css index b7e7b8d6d4d29..769658c837501 100644 --- a/packages/fiori/src/themes/IllustratedMessage.css +++ b/packages/fiori/src/themes/IllustratedMessage.css @@ -6,7 +6,6 @@ box-sizing: border-box; width: 100%; height:100%; - padding: 1rem; } .ui5-illustrated-message-root, @@ -17,10 +16,19 @@ align-items: center; justify-content: center; height: inherit; - min-height: 0; flex-basis: content; } +.ui5-illustrated-message-root, +.ui5-illustrated-message-inner { + min-height: 0; +} + +.ui5-illustrated-message-inner { + padding: 1rem; + box-sizing: border-box; +} + .ui5-illustrated-message-illustration { margin: 2rem 0; } @@ -30,24 +38,6 @@ max-width: 100%; } -.ui5-illustrated-message-illustration.ui5-illustrated-message-illustration-fit-content { - position: relative; - width: 0; - padding: 0; - padding-left: 100%; - height: 100%; - display: flex; - align-items: center; -} - -.ui5-illustrated-message-illustration.ui5-illustrated-message-illustration-fit-content svg { - position: absolute; - height: 100%; - width: 100%; - left: 0; - top: 0; -} - .ui5-illustrated-message-title [ui5-title], .ui5-illustrated-message-title ::slotted([slot="title"]) { text-align: center; @@ -93,7 +83,7 @@ margin-bottom: 1rem; } -:host([media="spot"]) { +:host([media="spot"]) .ui5-illustrated-message-inner { padding: 0.5rem; } @@ -119,7 +109,7 @@ margin: 0.5rem 0; } -:host([media="dot"]) { +:host([media="dot"]) .ui5-illustrated-message-inner { padding: 0.25rem; min-height: 4rem; /* needed to avoid infinite loop between "dot" and "base" when only illustration is used (no title, etc.) */ } diff --git a/packages/fiori/test/pages/IllustratedMessage.html b/packages/fiori/test/pages/IllustratedMessage.html index 59d356e948fed..8770f1effd7c4 100644 --- a/packages/fiori/test/pages/IllustratedMessage.html +++ b/packages/fiori/test/pages/IllustratedMessage.html @@ -198,13 +198,23 @@

Vertical responsiveness

500px 700px + Theme: + + sap_horizon + sap_horizon_hcb + sap_horizon_hcw + sap_fiori_3 + sap_fiori_3_hcb +
Action 1
-