diff --git a/packages/main/cypress/specs/PopoverResize.cy.tsx b/packages/main/cypress/specs/PopoverResize.cy.tsx new file mode 100644 index 000000000000..c284ca425757 --- /dev/null +++ b/packages/main/cypress/specs/PopoverResize.cy.tsx @@ -0,0 +1,979 @@ +import "@ui5/webcomponents-base/dist/features/F6Navigation.js"; +import Popover from "../../src/Popover.js"; +import Button from "../../src/Button.js"; + +describe("Popover Resize Functionality", () => { + beforeEach(() => { + cy.viewport(1200, 800); + }); + + describe("Resizable Property", () => { + it("should render resize handle when resizable is true", () => { + cy.mount( + <> + + +
Resizable content
+
+ + ); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popover-resize-handle") + .should("exist") + .and("be.visible"); + }); + + it("should not render resize handle when resizable is false", () => { + cy.mount( + <> + + +
Non-resizable content
+
+ + ); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popover-resize-handle") + .should("not.exist"); + }); + + it("should toggle resize handle when resizable property changes", () => { + cy.mount( + <> + + +
Content
+
+ + ); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popover-resize-handle") + .should("not.exist"); + + cy.get("[ui5-popover]").invoke("prop", "resizable", true); + + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popover-resize-handle") + .should("exist") + .and("be.visible"); + }); + }); + + describe("Resize Handle Placement", () => { + it("should position resize handle at bottom-right when popover is to the right of opener", () => { + cy.mount( + <> + + +
Content
+
+ + ); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popup-root") + .should("have.class", "ui5-popover-resize-handle-bottom-right"); + }); + + it("should position resize handle at top-left when popover is to the left of opener", () => { + cy.mount( + <> + + +
Content
+
+ + ); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popup-root") + .should("have.class", "ui5-popover-resize-handle-top-left"); + }); + + it("should position resize handle at top-right when popover is above opener", () => { + cy.mount( + <> + + +
Content
+
+ + ); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popup-root") + .should("have.class", "ui5-popover-resize-handle-top-right"); + }); + + it("should position resize handle at bottom-right when popover is below opener", () => { + cy.mount( + <> + + +
Content
+
+ + ); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popup-root") + .should("have.class", "ui5-popover-resize-handle-bottom-right"); + }); + }); + + describe("Resize Handle Placement in RTL", () => { + it("should position resize handle at top-left when popover is to the left of opener", () => { + cy.mount( +
+ + +
Content
+
+
+ ); + + cy.get("[ui5-popover]").invoke("prop", "open", true); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popup-root") + .should("have.class", "ui5-popover-resize-handle-top-left"); + }); + + it("should position resize handle at bottom-right when popover is to the right of opener", () => { + cy.mount( +
+ + +
Content
+
+
+ ); + + cy.get("[ui5-popover]").invoke("prop", "open", true); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popup-root") + .should("have.class", "ui5-popover-resize-handle-bottom-right"); + }); + + it("should position resize handle at top-left when popover is above opener", () => { + cy.mount( +
+ + +
Content
+
+
+ ); + + cy.get("[ui5-popover]").invoke("prop", "open", true); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popup-root") + .should("have.class", "ui5-popover-resize-handle-top-left"); + }); + + it("should position resize handle at bottom-left when popover is below opener", () => { + cy.mount( +
+ + +
Content
+
+
+ ); + + cy.get("[ui5-popover]").invoke("prop", "open", true); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popup-root") + .should("have.class", "ui5-popover-resize-handle-bottom-left"); + }); + }); + + describe("Resize Interaction", () => { + it("should resize correctly with Top placement", () => { + cy.mount( + <> + + +
+ Content +
+
+ + ); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + let initialSize: { width: number; height: number }; + cy.get("[ui5-popover]").then($popover => { + const rect = $popover[0].getBoundingClientRect(); + initialSize = { width: rect.width, height: rect.height }; + }); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popover-resize-handle") + .realMouseDown({ position: "center" }) + .realMouseMove(50, 50) + .realMouseUp(); + + cy.get("[ui5-popover]").then($popover => { + const rect = $popover[0].getBoundingClientRect(); + expect(rect.width).be.greaterThan(initialSize.width); + expect(rect.height).be.lessThan(initialSize.height); + }); + }); + + it("should resize correctly with Bottom placement", () => { + cy.mount( + <> + + +
+ Content +
+
+ + ); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + let initialWidth: number; + let initialHeight: number; + cy.get("[ui5-popover]").then($popover => { + initialWidth = $popover[0].getBoundingClientRect().width; + initialHeight = $popover[0].getBoundingClientRect().height; + }); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popover-resize-handle") + .realMouseDown({ position: "center" }) + .realMouseMove(50, 50) + .realMouseUp(); + + cy.get("[ui5-popover]").then($popover => { + const newWidth = $popover[0].getBoundingClientRect().width; + const newHeight = $popover[0].getBoundingClientRect().height; + + expect(newWidth).be.greaterThan(initialWidth); + expect(newHeight).be.greaterThan(initialHeight); + }); + }); + + it("should resize correctly with Start placement", () => { + cy.mount( + <> + + +
+ Content +
+
+ + ); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + let initialWidth: number; + let initialHeight: number; + cy.get("[ui5-popover]").then($popover => { + initialWidth = $popover[0].getBoundingClientRect().width; + initialHeight = $popover[0].getBoundingClientRect().height; + }); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popover-resize-handle") + .realMouseDown({ position: "center" }) + .realMouseMove(-50, -50) + .realMouseUp(); + + cy.get("[ui5-popover]").then($popover => { + const newWidth = $popover[0].getBoundingClientRect().width; + const newHeight = $popover[0].getBoundingClientRect().height; + + expect(newWidth).be.greaterThan(initialWidth); + expect(newHeight).be.greaterThan(initialHeight); + }); + }); + + it("should resize correctly with End placement", () => { + cy.mount( + <> + + +
+ Content +
+
+ + ); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + let initialWidth: number; + let initialHeight: number; + + cy.get("[ui5-popover]").then($popover => { + initialWidth = $popover[0].getBoundingClientRect().width; + initialHeight = $popover[0].getBoundingClientRect().height; + }); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popover-resize-handle") + .realMouseDown({ position: "center" }) + .realMouseMove(50, 50) + .realMouseUp(); + + cy.get("[ui5-popover]").then($popover => { + const newWidth = $popover[0].getBoundingClientRect().width; + const newHeight = $popover[0].getBoundingClientRect().height; + + expect(newWidth).be.greaterThan(initialWidth); + expect(newHeight).be.greaterThan(initialHeight); + }); + }); + + it("should respect minimum width/height during resize", () => { + cy.mount( + <> + + +
+ Content +
+
+ + ); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popover-resize-handle") + .realMouseDown({ position: "center" }) + .realMouseMove(-150, -150) + .realMouseUp(); + + cy.get("[ui5-popover]").then($popover => { + const width = $popover[0].getBoundingClientRect().width; + const height = $popover[0].getBoundingClientRect().height; + + expect(width).to.be.at.least(150); + expect(height).to.be.at.least(150); + }); + }); + + it("should respect viewport margins during resize", () => { + cy.mount( + <> + + +
+ Content +
+
+ + ); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popover-resize-handle") + .realMouseDown({ position: "center" }) + .realMouseMove(780, 570) + .realMouseUp(); + + cy.get("[ui5-popover]").then($popover => { + const rect = $popover[0].getBoundingClientRect(); + expect(rect.right).to.be.lessThan(window.innerWidth); + expect(rect.bottom).to.be.lessThan(window.innerHeight); + }); + }); + + it("should maintain resized size when popover is repositioned", () => { + cy.mount( + <> + + +
+ Content +
+
+ + ); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popover-resize-handle") + .realMouseDown({ position: "center" }) + .realMouseMove(50, 50) + .realMouseUp(); + + let resizedWidth: number; + let resizedHeight: number; + cy.get("[ui5-popover]").then($popover => { + const rect = $popover[0].getBoundingClientRect(); + resizedWidth = rect.width; + resizedHeight = rect.height; + }); + + // Trigger a reposition by resizing the window + cy.viewport(1300, 900); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + + cy.get("[ui5-popover]").then($popover => { + const rect = $popover[0].getBoundingClientRect(); + // The size should be maintained (with some tolerance for rounding) + expect(Math.abs(rect.width - resizedWidth)).to.be.lessThan(5); + expect(Math.abs(rect.height - resizedHeight)).to.be.lessThan(5); + }); + }); + }); + + describe("Resize Interaction in RTL", () => { + it("should resize correctly with Top placement", () => { + cy.mount( +
+ + +
+ Content +
+
+
+ ); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + let initialSize: { width: number; height: number }; + cy.get("[ui5-popover]").then($popover => { + const rect = $popover[0].getBoundingClientRect(); + initialSize = { width: rect.width, height: rect.height }; + }); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popover-resize-handle") + .realMouseDown({ position: "center" }) + .realMouseMove(-50, 50) + .realMouseUp(); + + cy.get("[ui5-popover]").then($popover => { + const rect = $popover[0].getBoundingClientRect(); + expect(rect.width).be.greaterThan(initialSize.width); + expect(rect.height).be.lessThan(initialSize.height); + }); + }); + + it("should resize correctly with Bottom placement", () => { + cy.mount( +
+ + +
+ Content +
+
+
+ ); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + let initialWidth: number; + let initialHeight: number; + cy.get("[ui5-popover]").then($popover => { + initialWidth = $popover[0].getBoundingClientRect().width; + initialHeight = $popover[0].getBoundingClientRect().height; + }); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popover-resize-handle") + .realMouseDown({ position: "center" }) + .realMouseMove(-50, 50) + .realMouseUp(); + + cy.get("[ui5-popover]").then($popover => { + const newWidth = $popover[0].getBoundingClientRect().width; + const newHeight = $popover[0].getBoundingClientRect().height; + + expect(newWidth).be.greaterThan(initialWidth); + expect(newHeight).be.greaterThan(initialHeight); + }); + }); + + it("should resize correctly with Start placement", () => { + cy.mount( +
+ + +
+ Content +
+
+
+ ); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + let initialWidth: number; + let initialHeight: number; + cy.get("[ui5-popover]").then($popover => { + initialWidth = $popover[0].getBoundingClientRect().width; + initialHeight = $popover[0].getBoundingClientRect().height; + }); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popover-resize-handle") + .realMouseDown({ position: "center" }) + .realMouseMove(50, 50) + .realMouseUp(); + + cy.get("[ui5-popover]").then($popover => { + const newWidth = $popover[0].getBoundingClientRect().width; + const newHeight = $popover[0].getBoundingClientRect().height; + + expect(newWidth).be.greaterThan(initialWidth); + expect(newHeight).be.greaterThan(initialHeight); + }); + }); + + it("should resize correctly with End placement", () => { + cy.mount( +
+ + +
+ Content +
+
+
+ ); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + let initialWidth: number; + let initialHeight: number; + + cy.get("[ui5-popover]").then($popover => { + initialWidth = $popover[0].getBoundingClientRect().width; + initialHeight = $popover[0].getBoundingClientRect().height; + }); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popover-resize-handle") + .realMouseDown({ position: "center" }) + .realMouseMove(-50, -50) + .realMouseUp(); + + cy.get("[ui5-popover]").then($popover => { + const newWidth = $popover[0].getBoundingClientRect().width; + const newHeight = $popover[0].getBoundingClientRect().height; + + expect(newWidth).be.greaterThan(initialWidth); + expect(newHeight).be.greaterThan(initialHeight); + }); + }); + }); + + describe("Resize State Reset", () => { + it("should reset size when popover is closed and reopened", () => { + cy.mount( + <> + + +
+ Content +
+
+ + ); + + cy.get("[ui5-popover]").invoke("prop", "open", true); + cy.get("[ui5-popover]").ui5PopoverOpened(); + + let initialWidth: number; + cy.get("[ui5-popover]").then($popover => { + initialWidth = $popover[0].getBoundingClientRect().width; + }); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popover-resize-handle") + .realMouseDown({ position: "center" }) + .realMouseMove(50, 50) + .realMouseUp(); + + cy.get("[ui5-popover]").then($popover => { + const resizedWidth = $popover[0].getBoundingClientRect().width; + expect(resizedWidth).be.greaterThan(initialWidth); + }); + + cy.get("[ui5-popover]").invoke("prop", "open", false); + cy.get("[ui5-popover]").should("not.be.visible"); + + cy.get("[ui5-popover]").invoke("prop", "open", true); + cy.get("[ui5-popover]").ui5PopoverOpened(); + + cy.get("[ui5-popover]").then($popover => { + const reopenedWidth = $popover[0].getBoundingClientRect().width; + expect(Math.abs(reopenedWidth - initialWidth)).to.be.lessThan(5); + }); + }); + }); + + describe("Resize with Modal Popover", () => { + it("should resize modal popover correctly", () => { + cy.mount( + <> + + +
+ Modal resizable content +
+
+ + ); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + let initialWidth: number; + let initialHeight: number; + cy.get("[ui5-popover]").then($popover => { + initialWidth = $popover[0].getBoundingClientRect().width; + initialHeight = $popover[0].getBoundingClientRect().height; + }); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popover-resize-handle") + .realMouseDown({ position: "center" }) + .realMouseMove(50, 50) + .realMouseUp(); + + cy.get("[ui5-popover]").then($popover => { + const newWidth = $popover[0].getBoundingClientRect().width; + const newHeight = $popover[0].getBoundingClientRect().height; + + expect(newWidth).be.greaterThan(initialWidth); + expect(newHeight).be.greaterThan(initialHeight); + }); + }); + }); + + describe("Resize with Header and Footer", () => { + it("should resize popover with header and footer correctly", () => { + cy.mount( + <> + + +
+ Content with header and footer +
+
+ +
+
+ + ); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + let initialSize: { width: number; height: number }; + cy.get("[ui5-popover]").then($popover => { + const rect = $popover[0].getBoundingClientRect(); + initialSize = { width: rect.width, height: rect.height }; + }); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popover-resize-handle") + .realMouseDown({ position: "center" }) + .realMouseMove(50, 50) + .realMouseUp(); + + cy.get("[ui5-popover]").then($popover => { + const rect = $popover[0].getBoundingClientRect(); + expect(rect.width).be.greaterThan(initialSize.width); + expect(rect.height).be.greaterThan(initialSize.height); + }); + }); + }); + + describe("Resize Handle Click Detection", () => { + it("should detect clicks on resize handle to prevent popover close", () => { + cy.mount( + <> + + +
+ Content +
+
+ + ); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popover-resize-handle") + .realMouseDown({ position: "center" }); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + }); + }); + + describe("Resize with Arrow", () => { + it("should resize popover with arrow correctly", () => { + cy.mount( + <> + + +
+ Content with arrow +
+
+ + ); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popover-arrow") + .should("be.visible"); + + let initialWidth: number; + let initialHeight: number; + cy.get("[ui5-popover]").then($popover => { + initialWidth = $popover[0].getBoundingClientRect().width; + initialHeight = $popover[0].getBoundingClientRect().height; + }); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popover-resize-handle") + .realMouseDown({ position: "center" }) + .realMouseMove(50, 50) + .realMouseUp(); + + cy.get("[ui5-popover]").then($popover => { + const newWidth = $popover[0].getBoundingClientRect().width; + const newHeight = $popover[0].getBoundingClientRect().height; + + expect(newWidth).be.greaterThan(initialWidth); + expect(newHeight).be.greaterThan(initialHeight); + }); + + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popover-arrow") + .should("be.visible"); + }); + + it("should resize popover without arrow correctly", () => { + cy.mount( + <> + + +
+ Content without arrow +
+
+ + ); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popover-arrow") + .should("not.be.visible"); + + let initialWidth: number; + let initialHeight: number; + cy.get("[ui5-popover]").then($popover => { + initialWidth = $popover[0].getBoundingClientRect().width; + initialHeight = $popover[0].getBoundingClientRect().height; + }); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popover-resize-handle") + .realMouseDown({ position: "center" }) + .realMouseMove(50, 50) + .realMouseUp(); + + cy.get("[ui5-popover]").then($popover => { + const newWidth = $popover[0].getBoundingClientRect().width; + const newHeight = $popover[0].getBoundingClientRect().height; + + expect(newWidth).be.greaterThan(initialWidth); + expect(newHeight).be.greaterThan(initialHeight); + }); + }); + }); +}); diff --git a/packages/main/src/Popover.ts b/packages/main/src/Popover.ts index 51751a3e44cb..8542c0f4255e 100644 --- a/packages/main/src/Popover.ts +++ b/packages/main/src/Popover.ts @@ -4,7 +4,7 @@ import customElement from "@ui5/webcomponents-base/dist/decorators/customElement import property from "@ui5/webcomponents-base/dist/decorators/property.js"; import slot from "@ui5/webcomponents-base/dist/decorators/slot.js"; import { isIOS } from "@ui5/webcomponents-base/dist/Device.js"; -import { getClosedPopupParent } from "@ui5/webcomponents-base/dist/util/PopupUtils.js"; +import { isClickInRect, getClosedPopupParent } from "@ui5/webcomponents-base/dist/util/PopupUtils.js"; import clamp from "@ui5/webcomponents-base/dist/util/clamp.js"; import DOMReferenceConverter from "@ui5/webcomponents-base/dist/converters/DOMReference.js"; import { renderFinished } from "@ui5/webcomponents-base/dist/Render.js"; @@ -53,6 +53,13 @@ type CalculatedPlacement = { actualPlacement: `${PopoverActualPlacement}`, } +enum ResizeHandlePlacement { + TopLeft = "TopLeft", + TopRight = "TopRight", + BottomLeft = "BottomLeft", + BottomRight = "BottomRight", +} + /** * @class * @@ -135,7 +142,7 @@ class Popover extends Popup { /** * Defines whether the component should close when - * clicking/tapping outside of the popover. + * clicking/tapping outside the popover. * If enabled, it blocks any interaction with the background. * @default false * @public @@ -161,6 +168,16 @@ class Popover extends Popup { @property({ type: Boolean }) allowTargetOverlap = false; + /** + * Determines whether the component is resizable. + * **Note:** This property is effective only on Desktop + * @default false + * @public + * @since 2.17.0 + */ + @property({ type: Boolean }) + resizable = false; + /** * Sets the X translation of the arrow * @private @@ -211,12 +228,40 @@ class Popover extends Popup { _width?: string; _height?: string; + _resizeMouseMoveHandler: (e: MouseEvent) => void; + _resizeMouseUpHandler: (e: MouseEvent) => void; + + _resizeHandlePlacement?: `${ResizeHandlePlacement}`; + + _initialClientX?: number; + _initialClientY?: number; + _initialBoundingRect?: DOMRect; + _minWidth?: number; + _minHeight?: number; + _resized = false; + + _currentResizeDeltaX?: number; + _currentResizeDeltaY?: number; + + // These variables track the cumulative resize difference throughout the entire resizing process. + // It covers scenarios where: the mouse is pressed down, + // moved, and released; the popover remains open; + // and the mouse is pressed down, moved, and released again. + _totalResizeDeltaX?: number; + _totalResizeDeltaY?: number; + + _initialWidth?: string; + _initialHeight?: string; + static get VIEWPORT_MARGIN() { return 10; // px } constructor() { super(); + + this._resizeMouseMoveHandler = this._onResizeMouseMove.bind(this); + this._resizeMouseUpHandler = this._onResizeMouseUp.bind(this); } /** @@ -262,11 +307,35 @@ class Popover extends Popup { return; } + this._initialWidth = this.style.width; + this._initialHeight = this.style.height; + this._openerRect = opener.getBoundingClientRect(); await super.openPopup(); } + closePopup(escPressed = false, preventRegistryUpdate = false, preventFocusRestore = false) : void { + Object.assign(this.style, { + width: this._initialWidth, + height: this._initialHeight, + }); + + if (this._resized) { + this._resized = false; + + delete this._currentResizeDeltaX; + delete this._currentResizeDeltaY; + + delete this._totalResizeDeltaX; + delete this._totalResizeDeltaY; + + delete this._resizeHandlePlacement; + } + + super.closePopup(escPressed, preventRegistryUpdate, preventFocusRestore); + } + isOpenerClicked(e: MouseEvent) { const target = e.target as HTMLElement; const opener = this.getOpenerHTMLElement(this.opener); @@ -286,6 +355,17 @@ class Popover extends Popup { return e.composedPath().indexOf(opener) > -1; } + isClicked(e: MouseEvent) { + if (this._showResizeHandle) { + const resizeHandle = this.shadowRoot!.querySelector(".ui5-popover-resize-handle"); + if (resizeHandle === e.composedPath()[0]) { + return true; + } + } + + return isClickInRect(e, this.getBoundingClientRect()); + } + /** * Override for the _addOpenedPopup hook, which would otherwise just call addOpenedPopup(this) * @private @@ -462,6 +542,10 @@ class Popover extends Popup { left: `${left}px`, }); + if (this._resized) { + return; + } + if (this.horizontalAlign === PopoverHorizontalAlign.Stretch && this._width) { this.style.width = this._width; } @@ -553,12 +637,14 @@ class Popover extends Popup { const isVertical = actualPlacement === PopoverActualPlacement.Top || actualPlacement === PopoverActualPlacement.Bottom; - if (this.horizontalAlign === PopoverHorizontalAlign.Stretch && isVertical) { - popoverSize.width = targetRect.width; - this._width = `${targetRect.width}px`; - } else if (this.verticalAlign === PopoverVerticalAlign.Stretch && !isVertical) { - popoverSize.height = targetRect.height; - this._height = `${targetRect.height}px`; + if (!this._resized) { + if (this.horizontalAlign === PopoverHorizontalAlign.Stretch && isVertical) { + popoverSize.width = targetRect.width; + this._width = `${targetRect.width}px`; + } else if (this.verticalAlign === PopoverVerticalAlign.Stretch && !isVertical) { + popoverSize.height = targetRect.height; + this._height = `${targetRect.height}px`; + } } const arrowOffset = this.hideArrow ? 0 : ARROW_SIZE; @@ -790,6 +876,9 @@ class Popover extends Popup { case PopoverActualHorizontalAlign.Center: case PopoverActualHorizontalAlign.Stretch: left = targetRect.left - (popoverSize.width - targetRect.width) / 2; + if (this._resized) { + left -= this._currentResizeDeltaX || 0; + } break; case PopoverActualHorizontalAlign.Left: left = targetRect.left; @@ -809,6 +898,9 @@ class Popover extends Popup { case PopoverVerticalAlign.Center: case PopoverVerticalAlign.Stretch: top = targetRect.top - (popoverSize.height - targetRect.height) / 2; + if (this._resized) { + top -= this._currentResizeDeltaY || 0; + } break; case PopoverVerticalAlign.Top: top = targetRect.top; @@ -849,6 +941,24 @@ class Popover extends Popup { get classes() { const allClasses = super.classes; allClasses.root["ui5-popover-root"] = true; + allClasses.root["ui5-popover-rtl"] = this.isRtl; + + if (this.resizable) { + switch (this._getResizeHandlePlacement()) { + case ResizeHandlePlacement.BottomLeft: + allClasses.root["ui5-popover-resize-handle-bottom-left"] = true; + break; + case ResizeHandlePlacement.BottomRight: + allClasses.root["ui5-popover-resize-handle-bottom-right"] = true; + break; + case ResizeHandlePlacement.TopLeft: + allClasses.root["ui5-popover-resize-handle-top-left"] = true; + break; + case ResizeHandlePlacement.TopRight: + allClasses.root["ui5-popover-resize-handle-top-right"] = true; + break; + } + } return allClasses; } @@ -884,6 +994,212 @@ class Popover extends Popup { return PopoverActualHorizontalAlign.Center; } } + + get _showResizeHandle() { + return this.resizable && this.onDesktop; + } + + _getResizeHandlePlacement() { + if (this._resizeHandlePlacement) { + return this._resizeHandlePlacement; + } + + const offset = 2; + const isRtl = this.isRtl; + + const opener = this.getOpenerHTMLElement(this.opener); + const openerRect = opener!.getBoundingClientRect(); + const popoverWrapperRect = this.getBoundingClientRect(); + + let openerCX = Math.floor(openerRect.x + openerRect.width / 2); + const openerCY = Math.floor(openerRect.y + openerRect.height / 2); + + let popoverCX = Math.floor(popoverWrapperRect.x + popoverWrapperRect.width / 2); + const popoverCY = Math.floor(popoverWrapperRect.y + popoverWrapperRect.height / 2); + + if (this.isRtl) { + openerCX = -openerCX; + popoverCX = -popoverCX; + } + + switch (this.getActualPlacement(openerRect)) { + case PopoverActualPlacement.Left: + if (popoverCY > openerCY + offset) { + return ResizeHandlePlacement.BottomLeft; + } + + return ResizeHandlePlacement.TopLeft; + case PopoverActualPlacement.Right: + if (popoverCY + offset < openerCY) { + return ResizeHandlePlacement.TopRight; + } + + return ResizeHandlePlacement.BottomRight; + case PopoverActualPlacement.Bottom: + if (popoverCX + offset < openerCX) { + return isRtl ? ResizeHandlePlacement.BottomRight : ResizeHandlePlacement.BottomLeft; + } + + return isRtl ? ResizeHandlePlacement.BottomLeft : ResizeHandlePlacement.BottomRight; + case PopoverActualPlacement.Top: + default: + if (popoverCX + offset < openerCX) { + return isRtl ? ResizeHandlePlacement.TopRight : ResizeHandlePlacement.TopLeft; + } + + return isRtl ? ResizeHandlePlacement.TopLeft : ResizeHandlePlacement.TopRight; + } + } + + _onResizeMouseDown(e: MouseEvent) { + if (!this.resizable) { + return; + } + + e.preventDefault(); + + this._resized = true; + this._initialBoundingRect = this.getBoundingClientRect(); + + this._totalResizeDeltaX = this._currentResizeDeltaX; + this._totalResizeDeltaY = this._currentResizeDeltaY; + + const { + minWidth, + minHeight, + } = window.getComputedStyle(this); + + const domRefComputedStyle = window.getComputedStyle(this._getRealDomRef!()); + + this._initialClientX = e.clientX; + this._initialClientY = e.clientY; + + this._minWidth = Math.max(Number.parseFloat(minWidth), Number.parseFloat(domRefComputedStyle.minWidth)); + this._minHeight = Number.parseFloat(minHeight); + + this._resizeHandlePlacement = this._getResizeHandlePlacement(); + + this._attachMouseResizeHandlers(); + } + + _onResizeMouseMove(e: MouseEvent) { + const margin = Popover.VIEWPORT_MARGIN; + const { clientX, clientY } = e; + const placement = this._resizeHandlePlacement; + const initialBoundingRect = this._initialBoundingRect!; + const deltaX = clientX - this._initialClientX!; + const deltaY = clientY - this._initialClientY!; + + let newWidth, + newHeight; + + // Determine if we're resizing from left or right edge + const isResizingFromLeft = placement === ResizeHandlePlacement.TopLeft + || placement === ResizeHandlePlacement.BottomLeft; + + const isResizingFromTop = placement === ResizeHandlePlacement.TopLeft + || placement === ResizeHandlePlacement.TopRight; + + // Calculate width changes + if (isResizingFromLeft) { + // Resizing from left edge - width increases when moving left (negative delta) + const maxWidthFromLeft = initialBoundingRect.x + initialBoundingRect.width - margin; + + newWidth = clamp( + initialBoundingRect.width - deltaX, + this._minWidth!, + maxWidthFromLeft, + ); + + // Adjust left position when resizing from left + // Ensure the left edge respects the viewport margin and the right edge position + const newLeft = clamp( + initialBoundingRect.x + deltaX, + margin, + initialBoundingRect.x + initialBoundingRect.width - this._minWidth!, + ); + + // Recalculate width based on actual left position to stay within viewport with margin + newWidth = Math.min(newWidth, initialBoundingRect.x + initialBoundingRect.width - newLeft); + + this._currentResizeDeltaX = (initialBoundingRect.x - newLeft) / 2; + } else { + // Resizing from right edge - width increases when moving right (positive delta) + const maxWidthFromRight = window.innerWidth - initialBoundingRect.x - margin; + + newWidth = clamp( + initialBoundingRect.width + deltaX, + this._minWidth!, + maxWidthFromRight, + ); + + this._currentResizeDeltaX = (initialBoundingRect.width - newWidth) / 2; + } + + // Calculate height changes + if (isResizingFromTop) { + // Resizing from top edge - height increases when moving up (negative delta) + const maxHeightFromTop = initialBoundingRect.y + initialBoundingRect.height - margin; + + newHeight = clamp( + initialBoundingRect.height - deltaY, + this._minHeight!, + maxHeightFromTop, + ); + + // Adjust top position when resizing from top + // Ensure the top edge respects the viewport margin and the bottom edge position + const newTop = clamp( + initialBoundingRect.y + deltaY, + margin, + initialBoundingRect.y + initialBoundingRect.height - this._minHeight!, + ); + + // Recalculate height based on actual top position to stay within viewport with margin + newHeight = Math.min(newHeight, initialBoundingRect.y + initialBoundingRect.height - newTop); + + this._currentResizeDeltaY = (initialBoundingRect.y - newTop) / 2; + } else { + // Resizing from bottom edge - height increases when moving down (positive delta) + const maxHeightFromBottom = window.innerHeight - initialBoundingRect.y - margin; + + newHeight = clamp( + initialBoundingRect.height + deltaY, + this._minHeight!, + maxHeightFromBottom, + ); + + this._currentResizeDeltaY = (initialBoundingRect.height - newHeight) / 2; + } + + this._currentResizeDeltaX += this._totalResizeDeltaX || 0; + this._currentResizeDeltaY += this._totalResizeDeltaY || 0; + + Object.assign(this.style, { + height: `${newHeight}px`, + width: `${newWidth}px`, + }); + } + + _onResizeMouseUp() { + delete this._initialClientX; + delete this._initialClientY; + delete this._initialBoundingRect; + delete this._minWidth; + delete this._minHeight; + + this._detachMouseResizeHandlers(); + } + + _attachMouseResizeHandlers() { + window.addEventListener("mousemove", this._resizeMouseMoveHandler); + window.addEventListener("mouseup", this._resizeMouseUpHandler); + } + + _detachMouseResizeHandlers() { + window.removeEventListener("mousemove", this._resizeMouseMoveHandler); + window.removeEventListener("mouseup", this._resizeMouseUpHandler); + } } const instanceOfPopover = (object: any): object is Popover => { diff --git a/packages/main/src/PopoverTemplate.tsx b/packages/main/src/PopoverTemplate.tsx index f31cb442d788..c5f9bfbfc124 100644 --- a/packages/main/src/PopoverTemplate.tsx +++ b/packages/main/src/PopoverTemplate.tsx @@ -1,3 +1,5 @@ +import Icon from "./Icon.js"; +import resizeCorner from "@ui5/webcomponents-icons/dist/resize-corner.js"; import type Popover from "./Popover.js"; import PopupTemplate from "./PopupTemplate.js"; import Title from "./Title.js"; @@ -32,5 +34,13 @@ function afterContent(this: Popover) { } + + {this._showResizeHandle && +
+ +
+ } ); } diff --git a/packages/main/src/popup-utils/PopoverRegistry.ts b/packages/main/src/popup-utils/PopoverRegistry.ts index c08664fa10fa..481e57af4ecb 100644 --- a/packages/main/src/popup-utils/PopoverRegistry.ts +++ b/packages/main/src/popup-utils/PopoverRegistry.ts @@ -1,4 +1,3 @@ -import { isClickInRect } from "@ui5/webcomponents-base/dist/util/PopupUtils.js"; import type { Interval } from "@ui5/webcomponents-base/dist/types.js"; import getActiveElement from "@ui5/webcomponents-base/dist/util/getActiveElement.js"; import getParentElement from "@ui5/webcomponents-base/dist/util/getParentElement.js"; @@ -100,7 +99,7 @@ const clickHandler = (event: MouseEvent) => { return; } - if (isClickInRect(event, popup.getBoundingClientRect())) { + if ((popup as Popover).isClicked(event)) { break; } diff --git a/packages/main/src/themes/Dialog.css b/packages/main/src/themes/Dialog.css index 0e3026922293..ee4490054197 100644 --- a/packages/main/src/themes/Dialog.css +++ b/packages/main/src/themes/Dialog.css @@ -139,22 +139,6 @@ color: var(--sapButton_Lite_TextColor); } -::slotted([slot="footer"]) { - height: var(--_ui5_dialog_footer_height); -} - -::slotted([slot="footer"][ui5-bar][design="Footer"]) { - border-top: none; -} - -::slotted([slot="header"][ui5-bar]) { - box-shadow: none; -} - -::slotted([slot="footer"][ui5-toolbar]) { - border: 0; -} - :host::backdrop { background-color: var(--_ui5_popup_block_layer_background); opacity: var(--_ui5_popup_block_layer_opacity); diff --git a/packages/main/src/themes/Popover.css b/packages/main/src/themes/Popover.css index 0a543e7d00e1..a85fa99ab4dd 100644 --- a/packages/main/src/themes/Popover.css +++ b/packages/main/src/themes/Popover.css @@ -90,3 +90,80 @@ :host([modal]) .ui5-block-layer { display: block; } + +/* resize handle */ + +.ui5-popover-resize-handle { + position: absolute; + width: 1.5rem; + height: 1.5rem; + border-radius: 50%; + z-index: 1; +} + +.ui5-popover-resize-handle [ui5-icon] { + position: absolute; + width: 1rem; + height: 1rem; + cursor: inherit; + color: var(--sapButton_Lite_TextColor); + --rotAngle: 0; + --scaleX: 1; + transform: rotate(var(--rotAngle)) scaleX(var(--scaleX)); +} + +.ui5-popover-rtl .ui5-popover-resize-handle [ui5-icon] { + --scaleX: -1; +} + +.ui5-popover-resize-handle-top-right .ui5-popover-resize-handle { + top: -0.5rem; + right: -0.5rem; + cursor: ne-resize; +} + +.ui5-popover-resize-handle-top-right .ui5-popover-resize-handle [ui5-icon] { + bottom: 0; + left: 0; + --rotAngle: 270deg; +} + +.ui5-popover-resize-handle-top-left .ui5-popover-resize-handle { + top: -0.5rem; + left: -0.5rem; + cursor: nw-resize; +} + +.ui5-popover-resize-handle-top-left .ui5-popover-resize-handle [ui5-icon] { + bottom: 0; + right: 0; + --rotAngle: 180deg; +} + +.ui5-popover-resize-handle-bottom-left .ui5-popover-resize-handle { + bottom: -0.5rem; + left: -0.5rem; + cursor: ne-resize; +} + +.ui5-popover-resize-handle-bottom-left .ui5-popover-resize-handle [ui5-icon] { + top: 0; + right: 0; + --rotAngle: 90deg; +} + +.ui5-popover-resize-handle-bottom-right .ui5-popover-resize-handle { + bottom: -0.5rem; + right: -0.5rem; + cursor: nw-resize; +} + +.ui5-popover-resize-handle-bottom-right .ui5-popover-resize-handle [ui5-icon] { + top: 0; + left: 0; +} + +.ui5-popover-resizing, +.ui5-popover-resizing * { + user-select: none !important; +} \ No newline at end of file diff --git a/packages/main/src/themes/PopupsCommon.css b/packages/main/src/themes/PopupsCommon.css index 450f6b3bde82..d4aab5d184f0 100644 --- a/packages/main/src/themes/PopupsCommon.css +++ b/packages/main/src/themes/PopupsCommon.css @@ -127,3 +127,19 @@ padding-left: var(--_ui5_popup_header_footer_padding_xl); padding-right: var(--_ui5_popup_header_footer_padding_xl); } + +::slotted([slot="footer"]) { + height: var(--_ui5_popup_footer_height); +} + +::slotted([slot="footer"][ui5-bar][design="Footer"]) { + border-top: none; +} + +::slotted([slot="header"][ui5-bar]) { + box-shadow: none; +} + +::slotted([slot="footer"][ui5-toolbar]) { + border: 0; +} \ No newline at end of file diff --git a/packages/main/src/themes/base/sizes-parameters.css b/packages/main/src/themes/base/sizes-parameters.css index 20eab2e85cca..3e9c5e1f7a64 100644 --- a/packages/main/src/themes/base/sizes-parameters.css +++ b/packages/main/src/themes/base/sizes-parameters.css @@ -64,9 +64,11 @@ --_ui5_datetime_timeview_phonemode_clocks_width: 24.5rem; --_ui5_datetime_dateview_phonemode_margin_bottom: 0; + /* Popup */ + --_ui5_popup_footer_height: 2.75rem; + /* Dialog */ --_ui5_dialog_content_min_height: 2.75rem; - --_ui5_dialog_footer_height: 2.75rem; --_ui5_input_inner_padding: 0 0.625rem; --_ui5_input_inner_padding_with_icon: 0 0.25rem 0 0.625rem; @@ -265,9 +267,11 @@ --_ui5_datetime_timeview_phonemode_clocks_width: 21.125rem; --_ui5_datetime_dateview_phonemode_margin_bottom: 3.125rem; + /* Popup */ + --_ui5_popup_footer_height: 2.5rem; + /* Dialog */ --_ui5_dialog_content_min_height: 2.5rem; - --_ui5_dialog_footer_height: 2.5rem; /* Form */ --_ui5_form_item_min_height: 2rem; diff --git a/packages/main/test/pages/PopoverResize.html b/packages/main/test/pages/PopoverResize.html new file mode 100644 index 000000000000..64b13e3e7381 --- /dev/null +++ b/packages/main/test/pages/PopoverResize.html @@ -0,0 +1,94 @@ + + + + + + + Popover Resize + + + + + + + + +
+ Popover Resize +
+
+ Placement + + Start + End + Top + Bottom + +
+
+ Horizontal Align + + Center + Start + End + Stretch + +
+
+ Vertical Align + + Center + Top + Bottom + Stretch + +
+
+ Hide Arrow + +
+
+
+ Open Popover + +
+ This is a Popover control. +
+ + OK + +
+
+
+ + diff --git a/packages/main/test/pages/PopoverResizeRTL.html b/packages/main/test/pages/PopoverResizeRTL.html new file mode 100644 index 000000000000..223b33489951 --- /dev/null +++ b/packages/main/test/pages/PopoverResizeRTL.html @@ -0,0 +1,94 @@ + + + + + + + Popover Resize in RTL mode + + + + + + + + +
+ Popover Resize +
+
+ Placement + + Start + End + Top + Bottom + +
+
+ Horizontal Align + + Center + Start + End + Stretch + +
+
+ Vertical Align + + Center + Top + Bottom + Stretch + +
+
+ Hide Arrow + +
+
+
+ Open Popover + +
+ This is a Popover control. +
+ + OK + +
+
+
+ + diff --git a/packages/main/test/pages/styles/PopoverResize.css b/packages/main/test/pages/styles/PopoverResize.css new file mode 100644 index 000000000000..dc197f08abe3 --- /dev/null +++ b/packages/main/test/pages/styles/PopoverResize.css @@ -0,0 +1,30 @@ +body { + background-color: var(--sapBackgroundColor); +} + +.pageContainer { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + padding: 1rem; +} + +h1 { + color: var(--sapGroup_TitleTextColor); + font-size: var(--sapFontHeader5Size); + font-family: var(--sapFontHeaderFamily); +} + +.popoverSettings div { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.popoverOpenerContainer { + flex: 1; + display: flex; + justify-content: center; + align-items: center; +} \ No newline at end of file diff --git a/packages/website/docs/_components_pages/main/Popover.mdx b/packages/website/docs/_components_pages/main/Popover.mdx index 8c03cf5479ab..5f1220911767 100644 --- a/packages/website/docs/_components_pages/main/Popover.mdx +++ b/packages/website/docs/_components_pages/main/Popover.mdx @@ -4,6 +4,7 @@ slug: ../Popover import Basic from "../../_samples/main/Popover/Basic/Basic.md"; import Placement from "../../_samples/main/Popover/Placement/Placement.md"; +import Resizable from "../../_samples/main/Popover/Resizable/Resizable.md"; <%COMPONENT_OVERVIEW%> @@ -18,4 +19,10 @@ import Placement from "../../_samples/main/Popover/Placement/Placement.md"; You can influence the placement of the popup. Note: if there is not enough space for the popup on the defined side, it will open on the side with enough space. - \ No newline at end of file + + +### Resizable +The Resizable sample demonstrates how the Popover component can be resized by dragging its edges. +This allows users to adjust the popup's width and height interactively, providing greater flexibility for content display. + + \ No newline at end of file diff --git a/packages/website/docs/_samples/main/Popover/Resizable/Resizable.md b/packages/website/docs/_samples/main/Popover/Resizable/Resizable.md new file mode 100644 index 000000000000..17798ecc59ab --- /dev/null +++ b/packages/website/docs/_samples/main/Popover/Resizable/Resizable.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/Popover/Resizable/main.js b/packages/website/docs/_samples/main/Popover/Resizable/main.js new file mode 100644 index 000000000000..28e046936663 --- /dev/null +++ b/packages/website/docs/_samples/main/Popover/Resizable/main.js @@ -0,0 +1,21 @@ +import "@ui5/webcomponents/dist/Dialog.js"; +import "@ui5/webcomponents/dist/Button.js"; +import "@ui5/webcomponents/dist/Toolbar.js"; +import "@ui5/webcomponents/dist/ToolbarButton.js"; + +var popoverOpener = document.getElementById("popoverOpener"); +var popover = document.getElementById("popover"); +var popoverClosers = popover.querySelector(".popoverCloser"); + +popoverOpener.accessibilityAttributes = { + hasPopup: "dialog", + controls: popover.id, +}; +popoverOpener.addEventListener("click", () => { + popover.open = true; +}); +popoverClosers.forEach(btn => { + btn.addEventListener("click", () => { + popover.open = false; + }); +}) \ No newline at end of file diff --git a/packages/website/docs/_samples/main/Popover/Resizable/sample.html b/packages/website/docs/_samples/main/Popover/Resizable/sample.html new file mode 100644 index 000000000000..f56e3f5ce0b6 --- /dev/null +++ b/packages/website/docs/_samples/main/Popover/Resizable/sample.html @@ -0,0 +1,28 @@ + + + + + + + + Sample + + + + + + Open Popover + + +

Resize this popover by dragging it by its resize handle.

+

This feature is available only on Desktop.

+ + + +
+ + + + + +