diff --git a/packages/main/cypress/specs/PopoverResize.cy.tsx b/packages/main/cypress/specs/PopoverResize.cy.tsx new file mode 100644 index 000000000000..f0a08e39d621 --- /dev/null +++ b/packages/main/cypress/specs/PopoverResize.cy.tsx @@ -0,0 +1,1526 @@ +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(149); + expect(height).to.be.at.least(149); + }); + }); + + 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); + }); + }); + }); + + describe("Opener at Viewport Edges", () => { + it("should position and resize popover correctly when opener is at top-left edge", () => { + cy.mount( + <> + + +
+ Content +
+
+ + ); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + + // Verify correct resize handle placement class + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popup-root") + .should("have.class", "ui5-popover-resize-handle-bottom-right"); + + let initialWidth: number; + let initialHeight: number; + cy.get("[ui5-popover]").then($popover => { + initialWidth = $popover[0].getBoundingClientRect().width; + initialHeight = $popover[0].getBoundingClientRect().height; + }); + + // Test resizing + 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(initialWidth); + expect(rect.height).be.greaterThan(initialHeight); + // Ensure it stays within viewport + expect(rect.right).to.be.lessThan(window.innerWidth); + expect(rect.bottom).to.be.lessThan(window.innerHeight); + }); + }); + + it("should position and resize popover correctly when opener is at top-right edge", () => { + cy.mount( + <> + + +
+ Content +
+
+ + ); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + + // Verify popover is visible and positioned correctly + cy.get("[ui5-popover]").then($popover => { + const rect = $popover[0].getBoundingClientRect(); + expect(rect.right).to.be.at.most(window.innerWidth); + expect(rect.top).to.be.at.least(0); + }); + + // Verify correct resize handle placement class + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popup-root") + .should("have.class", "ui5-popover-resize-handle-bottom-left"); + + let initialHeight: number; + cy.get("[ui5-popover]").then($popover => { + initialHeight = $popover[0].getBoundingClientRect().height; + }); + + // Test resizing - try to expand but respect viewport bounds + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popover-resize-handle") + .realMouseDown({ position: "center" }) + .realMouseMove(30, 50) + .realMouseUp(); + + cy.get("[ui5-popover]").then($popover => { + const rect = $popover[0].getBoundingClientRect(); + expect(rect.height).be.greaterThan(initialHeight); + // Ensure it stays within viewport + expect(rect.right).to.be.lessThan(window.innerWidth); + expect(rect.bottom).to.be.lessThan(window.innerHeight); + }); + }); + + it("should position and resize popover correctly when opener is at bottom-left edge", () => { + cy.mount( + <> + + +
+ Content +
+
+ + ); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + // Verify popover is visible and positioned correctly + cy.get("[ui5-popover]").then($popover => { + const rect = $popover[0].getBoundingClientRect(); + expect(rect.left).to.be.at.least(0); + expect(rect.bottom).to.be.at.most(window.innerHeight); + }); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + + // Verify correct resize handle placement class + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popup-root") + .should("have.class", "ui5-popover-resize-handle-top-right"); + + let initialWidth: number; + cy.get("[ui5-popover]").then($popover => { + initialWidth = $popover[0].getBoundingClientRect().width; + }); + + // Test resizing + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popover-resize-handle") + .realMouseDown({ position: "center" }) + .realMouseMove(50, 30) + .realMouseUp(); + + cy.get("[ui5-popover]").then($popover => { + const rect = $popover[0].getBoundingClientRect(); + expect(rect.width).be.greaterThan(initialWidth); + // Ensure it stays within viewport + expect(rect.left).to.be.at.least(0); + expect(rect.top).to.be.at.least(0); + expect(rect.right).to.be.lessThan(window.innerWidth); + }); + }); + + it("should position and resize popover correctly when opener is at bottom-right edge", () => { + cy.mount( + <> + + +
+ Content +
+
+ + ); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + // Verify popover is visible and positioned correctly + cy.get("[ui5-popover]").then($popover => { + const rect = $popover[0].getBoundingClientRect(); + expect(rect.right).to.be.at.most(window.innerWidth); + expect(rect.bottom).to.be.at.most(window.innerHeight); + }); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + + // Verify correct resize handle placement class + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popup-root") + .should("have.class", "ui5-popover-resize-handle-top-left"); + + // Test resizing - should respect viewport boundaries + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popover-resize-handle") + .realMouseDown({ position: "center" }) + .realMouseMove(20, 20) + .realMouseUp(); + + cy.get("[ui5-popover]").then($popover => { + const rect = $popover[0].getBoundingClientRect(); + // Ensure it stays within viewport + expect(rect.left).to.be.at.least(0); + expect(rect.top).to.be.at.least(0); + expect(rect.right).to.be.lessThan(window.innerWidth); + expect(rect.bottom).to.be.lessThan(window.innerHeight); + }); + }); + + it("should position and resize popover correctly when opener is at left edge (middle)", () => { + cy.mount( + <> + + +
+ Content +
+
+ + ); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + + // Verify popover is visible and positioned correctly + cy.get("[ui5-popover]").then($popover => { + const rect = $popover[0].getBoundingClientRect(); + expect(rect.left).to.be.at.least(0); + }); + + let initialWidth: number; + let initialHeight: number; + cy.get("[ui5-popover]").then($popover => { + initialWidth = $popover[0].getBoundingClientRect().width; + initialHeight = $popover[0].getBoundingClientRect().height; + }); + + // Test resizing + 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(initialWidth); + expect(rect.height).be.greaterThan(initialHeight); + // Ensure it stays within viewport + expect(rect.right).to.be.lessThan(window.innerWidth); + }); + }); + + it("should position and resize popover correctly when opener is at right edge (middle)", () => { + cy.mount( + <> + + +
+ Content +
+
+ + ); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + + // Verify popover is visible and positioned correctly + cy.get("[ui5-popover]").then($popover => { + const rect = $popover[0].getBoundingClientRect(); + expect(rect.right).to.be.at.most(window.innerWidth); + }); + + let initialWidth: number; + let initialHeight: number; + cy.get("[ui5-popover]").then($popover => { + initialWidth = $popover[0].getBoundingClientRect().width; + initialHeight = $popover[0].getBoundingClientRect().height; + }); + + // Test resizing + 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(initialWidth); + expect(rect.height).be.greaterThan(initialHeight); + // Ensure it stays within viewport + expect(rect.left).to.be.at.least(0); + }); + }); + }); + + describe("Opener Bigger Than Popover", () => { + it("should position and resize popover correctly when opener is wider than popover", () => { + cy.mount( + <> + + +
+ Small Content +
+
+ + ); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + + // Verify popover is visible + cy.get("[ui5-popover]").then($popover => { + const popoverRect = $popover[0].getBoundingClientRect(); + cy.get("#btnOpen").then($button => { + const buttonRect = $button[0].getBoundingClientRect(); + // Popover should be narrower than opener + expect(popoverRect.width).to.be.lessThan(buttonRect.width); + }); + }); + + // Verify correct resize handle placement class (popover is narrower, so handle should be based on horizontal alignment) + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popup-root") + .should("have.class", "ui5-popover-resize-handle-bottom-right"); + + let initialWidth: number; + let initialHeight: number; + cy.get("[ui5-popover]").then($popover => { + initialWidth = $popover[0].getBoundingClientRect().width; + initialHeight = $popover[0].getBoundingClientRect().height; + }); + + // Test resizing - expand to match or exceed opener width + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popover-resize-handle") + .realMouseDown({ position: "center" }) + .realMouseMove(100, 50) + .realMouseUp(); + + cy.get("[ui5-popover]").then($popover => { + const rect = $popover[0].getBoundingClientRect(); + expect(rect.width).be.greaterThan(initialWidth); + expect(rect.height).be.greaterThan(initialHeight); + }); + }); + + it("should position and resize popover correctly when opener is taller than popover", () => { + cy.mount( + <> + + +
+ Small Content +
+
+ + ); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + + // Verify popover is visible + cy.get("[ui5-popover]").then($popover => { + const popoverRect = $popover[0].getBoundingClientRect(); + cy.get("#btnOpen").then($button => { + const buttonRect = $button[0].getBoundingClientRect(); + // Popover should be shorter than opener + expect(popoverRect.height).to.be.lessThan(buttonRect.height); + }); + }); + + // Verify correct resize handle placement class (popover is shorter, so handle should be at top) + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popup-root") + .should("have.class", "ui5-popover-resize-handle-bottom-right"); + + let initialWidth: number; + let initialHeight: number; + cy.get("[ui5-popover]").then($popover => { + initialWidth = $popover[0].getBoundingClientRect().width; + initialHeight = $popover[0].getBoundingClientRect().height; + }); + + // Test resizing - expand to match or exceed opener height + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popover-resize-handle") + .realMouseDown({ position: "center" }) + .realMouseMove(50, 100) + .realMouseUp(); + + cy.get("[ui5-popover]").then($popover => { + const rect = $popover[0].getBoundingClientRect(); + expect(rect.width).be.greaterThan(initialWidth); + expect(rect.height).be.greaterThan(initialHeight); + }); + }); + + it("should position and resize popover correctly when opener is much larger in both dimensions", () => { + cy.mount( + <> + + +
+ Tiny Content +
+
+ + ); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + + // Verify popover is much smaller than opener + cy.get("[ui5-popover]").then($popover => { + const popoverRect = $popover[0].getBoundingClientRect(); + cy.get("#btnOpen").then($button => { + const buttonRect = $button[0].getBoundingClientRect(); + expect(popoverRect.width).to.be.lessThan(buttonRect.width); + expect(popoverRect.height).to.be.lessThan(buttonRect.height); + }); + }); + + // Verify correct resize handle placement class (popover is much smaller, so handle at bottom-left based on center positions) + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popup-root") + .should("have.class", "ui5-popover-resize-handle-bottom-right"); + + let initialWidth: number; + let initialHeight: number; + cy.get("[ui5-popover]").then($popover => { + initialWidth = $popover[0].getBoundingClientRect().width; + initialHeight = $popover[0].getBoundingClientRect().height; + }); + + // Test resizing - expand significantly + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popover-resize-handle") + .realMouseDown({ position: "center" }) + .realMouseMove(150, 100) + .realMouseUp(); + + cy.get("[ui5-popover]").then($popover => { + const rect = $popover[0].getBoundingClientRect(); + expect(rect.width).be.greaterThan(initialWidth); + expect(rect.height).be.greaterThan(initialHeight); + // Ensure it stays within viewport + expect(rect.right).to.be.lessThan(window.innerWidth); + expect(rect.bottom).to.be.lessThan(window.innerHeight); + }); + }); + + it("should handle resizing when opener is larger and positioned at viewport edge", () => { + cy.mount( + <> + + +
+ Content +
+
+ + ); + + cy.get("[ui5-popover]").ui5PopoverOpened(); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + + // Verify popover is smaller than opener + cy.get("[ui5-popover]").then($popover => { + const popoverRect = $popover[0].getBoundingClientRect(); + cy.get("#btnOpen").then($button => { + const buttonRect = $button[0].getBoundingClientRect(); + expect(popoverRect.width).to.be.lessThan(buttonRect.width); + expect(popoverRect.height).to.be.lessThan(buttonRect.height); + }); + }); + + // Verify correct resize handle placement class (smaller popover at edge, handle at bottom-left) + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popup-root") + .should("have.class", "ui5-popover-resize-handle-bottom-left"); + + let initialWidth: number; + let initialHeight: number; + cy.get("[ui5-popover]").then($popover => { + initialWidth = $popover[0].getBoundingClientRect().width; + initialHeight = $popover[0].getBoundingClientRect().height; + }); + + // Test resizing with viewport constraints + cy.get("[ui5-popover]") + .shadow() + .find(".ui5-popover-resize-handle") + .realMouseDown({ position: "center" }) + .realMouseMove(-100, 80) + .realMouseUp(); + + cy.get("[ui5-popover]").then($popover => { + const rect = $popover[0].getBoundingClientRect(); + expect(rect.width).be.greaterThan(initialWidth); + expect(rect.height).be.greaterThan(initialHeight); + // Ensure it stays within viewport + expect(rect.left).to.be.at.least(0); + expect(rect.top).to.be.at.least(0); + expect(rect.right).to.be.lessThan(window.innerWidth); + expect(rect.bottom).to.be.lessThan(window.innerHeight); + }); + }); + }); +}); diff --git a/packages/main/src/Popover.ts b/packages/main/src/Popover.ts index 51751a3e44cb..0ad60d423e3b 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"; @@ -13,6 +13,7 @@ import PopoverPlacement from "./types/PopoverPlacement.js"; import PopoverVerticalAlign from "./types/PopoverVerticalAlign.js"; import PopoverHorizontalAlign from "./types/PopoverHorizontalAlign.js"; import { addOpenedPopover, removeOpenedPopover } from "./popup-utils/PopoverRegistry.js"; +import PopoverResize from "./PopoverResize.js"; // Template import PopoverTemplate from "./PopoverTemplate.js"; @@ -135,7 +136,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 +162,16 @@ class Popover extends Popup { @property({ type: Boolean }) allowTargetOverlap = false; + /** + * Determines whether the component is resizable. + * **Note:** This property is effective only on desktop devices. + * @default false + * @public + * @since 2.18.0 + */ + @property({ type: Boolean }) + resizable = false; + /** * Sets the X translation of the arrow * @private @@ -211,12 +222,19 @@ class Popover extends Popup { _width?: string; _height?: string; + _popoverResize: PopoverResize; + + _initialWidth?: string; + _initialHeight?: string; + static get VIEWPORT_MARGIN() { return 10; // px } constructor() { super(); + + this._popoverResize = new PopoverResize(this); } /** @@ -262,11 +280,25 @@ 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, + }); + + this._popoverResize.reset(); + + super.closePopup(escPressed, preventRegistryUpdate, preventFocusRestore); + } + isOpenerClicked(e: MouseEvent) { const target = e.target as HTMLElement; const opener = this.getOpenerHTMLElement(this.opener); @@ -286,6 +318,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 @@ -378,6 +421,10 @@ class Popover extends Popup { } } + get _viewportMargin() { + return Popover.VIEWPORT_MARGIN; + } + reposition() { this._show(); } @@ -462,6 +509,10 @@ class Popover extends Popup { left: `${left}px`, }); + if (this._popoverResize.isResized) { + return; + } + if (this.horizontalAlign === PopoverHorizontalAlign.Stretch && this._width) { this.style.width = this._width; } @@ -553,12 +604,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._popoverResize.isResized) { + 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; @@ -642,6 +695,10 @@ class Popover extends Popup { }; } + get isVertical() : boolean { + return this.placement === PopoverPlacement.Top || this.placement === PopoverPlacement.Bottom; + } + getRTLCorrectionLeft() { return parseFloat(window.getComputedStyle(this).left) - this.getBoundingClientRect().left; } @@ -725,7 +782,6 @@ class Popover extends Popup { getActualPlacement(targetRect: DOMRect): `${PopoverActualPlacement}` { const placement = this.placement; - const isVertical = placement === PopoverPlacement.Top || placement === PopoverPlacement.Bottom; const popoverSize = this.getPopoverSize(!this.allowTargetOverlap); let actualPlacement: PopoverActualPlacement = PopoverActualPlacement.Right; @@ -749,7 +805,7 @@ class Popover extends Popup { let clientHeight = document.documentElement.clientHeight; let popoverHeight = popoverSize.height; - if (isVertical) { + if (this.isVertical) { popoverHeight += this.hideArrow ? 0 : ARROW_SIZE; clientHeight -= Popover.VIEWPORT_MARGIN; } @@ -790,6 +846,7 @@ class Popover extends Popup { case PopoverActualHorizontalAlign.Center: case PopoverActualHorizontalAlign.Stretch: left = targetRect.left - (popoverSize.width - targetRect.width) / 2; + left = this._popoverResize.getCorrectedLeft(left); break; case PopoverActualHorizontalAlign.Left: left = targetRect.left; @@ -809,6 +866,7 @@ class Popover extends Popup { case PopoverVerticalAlign.Center: case PopoverVerticalAlign.Stretch: top = targetRect.top - (popoverSize.height - targetRect.height) / 2; + top = this._popoverResize.getCorrectedTop(top); break; case PopoverVerticalAlign.Top: top = targetRect.top; @@ -849,6 +907,11 @@ 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) { + this._popoverResize.setCorrectResizeHandleClass(allClasses); + } return allClasses; } @@ -884,6 +947,14 @@ class Popover extends Popup { return PopoverActualHorizontalAlign.Center; } } + + get _showResizeHandle() { + return this.resizable && this.onDesktop; + } + + _onResizeMouseDown(e: MouseEvent) { + this._popoverResize.onResizeMouseDown(e); + } } const instanceOfPopover = (object: any): object is Popover => { @@ -894,4 +965,4 @@ Popover.define(); export default Popover; -export { instanceOfPopover }; +export { instanceOfPopover, PopoverActualPlacement, PopoverActualHorizontalAlign }; diff --git a/packages/main/src/PopoverResize.ts b/packages/main/src/PopoverResize.ts new file mode 100644 index 000000000000..48599863cde8 --- /dev/null +++ b/packages/main/src/PopoverResize.ts @@ -0,0 +1,400 @@ +import clamp from "@ui5/webcomponents-base/dist/util/clamp.js"; +import type { ClassMap } from "@ui5/webcomponents-base/dist/types.js"; +import type Popover from "./Popover.js"; +import { PopoverActualPlacement, PopoverActualHorizontalAlign } from "./Popover.js"; +import PopoverVerticalAlign from "./types/PopoverVerticalAlign.js"; + +enum ResizeHandlePlacement { + TopLeft = "TopLeft", + TopRight = "TopRight", + BottomLeft = "BottomLeft", + BottomRight = "BottomRight", +} + +/** + * Manages resize functionality for Popover components + * @private + */ +class PopoverResize { + private _popover: Popover; + private _resizeMouseMoveHandler: (e: MouseEvent) => void; + private _resizeMouseUpHandler: (e: MouseEvent) => void; + + _resizeHandlePlacement?: `${ResizeHandlePlacement}`; + _initialClientX?: number; + _initialClientY?: number; + _initialBoundingRect?: DOMRect; + _minWidth?: number; + _minHeight?: number; + _resized = false; + + _currentDeltaX?: number; + _currentDeltaY?: 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. + _totalDeltaX?: number; + _totalDeltaY?: number; + + constructor(popover: Popover) { + this._popover = popover; + this._resizeMouseMoveHandler = this._onResizeMouseMove.bind(this); + this._resizeMouseUpHandler = this._onResizeMouseUp.bind(this); + } + + /** + * Resets the resize state + */ + reset() { + if (!this._resized) { + return; + } + + this._resized = false; + + delete this._currentDeltaX; + delete this._currentDeltaY; + + delete this._totalDeltaX; + delete this._totalDeltaY; + + delete this._resizeHandlePlacement; + } + + /** + * Returns whether the popover has been resized + */ + get isResized(): boolean { + return this._resized; + } + + /* + * Gets the corrected left position considering resize deltas + */ + getCorrectedLeft(left: number): number { + if (this.isResized) { + left -= this._currentDeltaX || 0; + } + + return left; + } + + /* + * Gets the corrected top position considering resize deltas + */ + getCorrectedTop(top: number): number { + if (this.isResized) { + top -= this._currentDeltaY || 0; + } + + return top; + } + + setCorrectResizeHandleClass(allClasses: ClassMap) { + 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; + } + } + + getResizeHandlePlacement() { + if (this._resizeHandlePlacement) { + return this._resizeHandlePlacement; + } + + const popover = this._popover; + const offset = 2; + const isRtl = popover.isRtl; + + const opener = popover.getOpenerHTMLElement(popover.opener); + + if (!opener) { + return ResizeHandlePlacement.BottomRight; + } + + const openerRect = opener.getBoundingClientRect(); + const popoverWrapperRect = popover.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); + + const verticalAlign = popover.verticalAlign; + const actualHorizontalAlign = popover._actualHorizontalAlign; + + const isPopoverWidthBiggerThanOpener = popoverWrapperRect.width > openerRect.width; + const isPopoverHeightBiggerThanOpener = popoverWrapperRect.height > openerRect.height; + + if (isRtl) { + openerCX = -openerCX; + popoverCX = -popoverCX; + } + + switch (popover.getActualPlacement(openerRect)) { + case PopoverActualPlacement.Left: + if (isPopoverHeightBiggerThanOpener) { + if (popoverCY > openerCY + offset) { + return ResizeHandlePlacement.BottomLeft; + } + + return ResizeHandlePlacement.TopLeft; + } + + if (verticalAlign === PopoverVerticalAlign.Top) { + return ResizeHandlePlacement.BottomLeft; + } + + return ResizeHandlePlacement.TopLeft; + case PopoverActualPlacement.Right: + if (isPopoverHeightBiggerThanOpener) { + if (popoverCY + offset < openerCY) { + return ResizeHandlePlacement.TopRight; + } + + return ResizeHandlePlacement.BottomRight; + } + + if (verticalAlign === PopoverVerticalAlign.Bottom) { + return ResizeHandlePlacement.TopRight; + } + + return ResizeHandlePlacement.BottomRight; + case PopoverActualPlacement.Bottom: + if (isPopoverWidthBiggerThanOpener) { + if (popoverCX + offset < openerCX) { + return isRtl ? ResizeHandlePlacement.BottomRight : ResizeHandlePlacement.BottomLeft; + } + + return isRtl ? ResizeHandlePlacement.BottomLeft : ResizeHandlePlacement.BottomRight; + } + + if (isRtl) { + if (actualHorizontalAlign === PopoverActualHorizontalAlign.Left) { + return ResizeHandlePlacement.BottomRight; + } + + return ResizeHandlePlacement.BottomLeft; + } + + if (actualHorizontalAlign === PopoverActualHorizontalAlign.Right) { + return ResizeHandlePlacement.BottomLeft; + } + + return ResizeHandlePlacement.BottomRight; + case PopoverActualPlacement.Top: + default: + if (isPopoverWidthBiggerThanOpener) { + if (popoverCX + offset < openerCX) { + return isRtl ? ResizeHandlePlacement.TopRight : ResizeHandlePlacement.TopLeft; + } + + return isRtl ? ResizeHandlePlacement.TopLeft : ResizeHandlePlacement.TopRight; + } + + if (isRtl) { + if (actualHorizontalAlign === PopoverActualHorizontalAlign.Left) { + return ResizeHandlePlacement.TopRight; + } + + return ResizeHandlePlacement.TopLeft; + } + + if (actualHorizontalAlign === PopoverActualHorizontalAlign.Right) { + return ResizeHandlePlacement.TopLeft; + } + + return ResizeHandlePlacement.TopRight; + } + } + + /** + * Handles mouse down event on resize handle + */ + onResizeMouseDown(e: MouseEvent) { + if (!this._popover.resizable) { + return; + } + + e.preventDefault(); + + this._resized = true; + this._initialBoundingRect = this._popover.getBoundingClientRect(); + + this._totalDeltaX = this._currentDeltaX; + this._totalDeltaY = this._currentDeltaY; + + const { + minWidth, + minHeight, + } = window.getComputedStyle(this._popover); + + const domRefComputedStyle = window.getComputedStyle(this._popover._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(); + } + + /** + * Handles mouse move event during resize + */ + private _onResizeMouseMove(e: MouseEvent) { + const margin = this._popover._viewportMargin; + const { clientX, clientY } = e; + const resizeHandlePlacement = 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 = resizeHandlePlacement === ResizeHandlePlacement.TopLeft + || resizeHandlePlacement === ResizeHandlePlacement.BottomLeft; + + const isResizingFromTop = resizeHandlePlacement === ResizeHandlePlacement.TopLeft + || resizeHandlePlacement === 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._currentDeltaX = (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._currentDeltaX = (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._currentDeltaY = (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._currentDeltaY = (initialBoundingRect.height - newHeight) / 2; + } + + this._currentDeltaX += this._totalDeltaX || 0; + this._currentDeltaY += this._totalDeltaY || 0; + + const placement = this._popover.calcPlacement(this._popover._openerRect!, { + width: newWidth, + height: newHeight, + }); + + this._popover.arrowTranslateX = placement.arrow.x; + this._popover.arrowTranslateY = placement.arrow.y; + + Object.assign(this._popover.style, { + left: `${placement.left}px`, + top: `${placement.top}px`, + height: `${newHeight}px`, + width: `${newWidth}px`, + }); + } + + /** + * Handles mouse up event after resize + */ + private _onResizeMouseUp() { + delete this._initialClientX; + delete this._initialClientY; + delete this._initialBoundingRect; + delete this._minWidth; + delete this._minHeight; + + this._detachMouseResizeHandlers(); + } + + /** + * Attaches mouse event handlers for resize + */ + private _attachMouseResizeHandlers() { + window.addEventListener("mousemove", this._resizeMouseMoveHandler); + window.addEventListener("mouseup", this._resizeMouseUpHandler); + } + + /** + * Detaches mouse event handlers for resize + */ + private _detachMouseResizeHandlers() { + window.removeEventListener("mousemove", this._resizeMouseMoveHandler); + window.removeEventListener("mouseup", this._resizeMouseUpHandler); + } +} + +export default PopoverResize; 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..d7b71794f74b 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"; @@ -92,16 +91,20 @@ const clickHandler = (event: MouseEvent) => { } // loop all open popovers - for (let i = (openedPopups.length - 1); i !== -1; i--) { + for (let i = openedPopups.length - 1; i !== -1; i--) { const popup = openedPopups[i].instance; + if (!instanceOfPopover(popup)) { + return; + } + // if popup is modal, opener is clicked, popup is dialog skip closing - if (popup.isModal || (popup as Popover).isOpenerClicked(event)) { + if (popup.isModal || popup.isOpenerClicked(event)) { return; } - if (isClickInRect(event, popup.getBoundingClientRect())) { - break; + if (popup.isClicked(event)) { + return; } popup.closePopup(); 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..f5a534708e35 --- /dev/null +++ b/packages/website/docs/_samples/main/Popover/Resizable/sample.html @@ -0,0 +1,28 @@ + + + + + + + + Sample + + + + + + Open Popover + + +

Resize this popover by dragging its resize handle.

+

This feature is available only on desktop devices.

+ + + +
+ + + + + +