diff --git a/packages/main/src/Popover.ts b/packages/main/src/Popover.ts index 51751a3e44cb..fc52e7207018 100644 --- a/packages/main/src/Popover.ts +++ b/packages/main/src/Popover.ts @@ -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,27 @@ class Popover extends Popup { _width?: string; _height?: string; + _resizeMouseMoveHandler: (e: MouseEvent) => void; + _resizeMouseUpHandler: (e: MouseEvent) => void; + _initialClientX?: number; + _initialClientY?: number; + + _initialBoundingRect?: DOMRect; + _minWidth?: number; + _minHeight?: number; + _resized = false; + + _resizeHandlePlacement?: `${ResizeHandlePlacement}`; + static get VIEWPORT_MARGIN() { return 10; // px } constructor() { super(); + + this._resizeMouseMoveHandler = this._onResizeMouseMove.bind(this); + this._resizeMouseUpHandler = this._onResizeMouseUp.bind(this); } /** @@ -850,6 +882,23 @@ class Popover extends Popup { const allClasses = super.classes; allClasses.root["ui5-popover-root"] = true; + 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 +933,216 @@ 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 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 ResizeHandlePlacement.BottomLeft; + } + + return ResizeHandlePlacement.BottomRight; + case PopoverActualPlacement.Top: + default: + if (popoverCX + offset < openerCX) { + return ResizeHandlePlacement.TopLeft; + } + + return ResizeHandlePlacement.TopRight; + } + } + + _onResizeMouseDown(e: MouseEvent) { + if (!this.resizable) { + return; + } + + e.preventDefault(); + + this._resized = true; + this._initialBoundingRect = this.getBoundingClientRect(); + + 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 newLeft = initialBoundingRect.x; + let newTop = initialBoundingRect.y; + + 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 + 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); + } 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, + ); + } + + // 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 + 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); + } 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, + ); + } + + Object.assign(this.style, { + height: `${newHeight}px`, + width: `${newWidth}px`, + left: `${newLeft}px`, + top: `${newTop}px`, + }); + } + + _onResizeMouseUp() { + delete this._initialClientX; + delete this._initialClientY; + delete this._initialBoundingRect; + delete this._minWidth; + delete this._minHeight; + + delete this._resizeHandlePlacement; + + this._detachMouseResizeHandlers(); + } + + _attachMouseResizeHandlers() { + window.addEventListener("mousemove", this._resizeMouseMoveHandler); + window.addEventListener("mouseup", this._resizeMouseUpHandler); + this.addEventListener("ui5-before-close", this._revertSize, { once: true }); + } + + _detachMouseResizeHandlers() { + window.removeEventListener("mousemove", this._resizeMouseMoveHandler); + window.removeEventListener("mouseup", this._resizeMouseUpHandler); + } + + _revertSize = () => { + this._resized = false; + + Object.assign(this.style, { + top: "", + left: "", + width: "", + height: "", + }); + } } 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/themes/Popover.css b/packages/main/src/themes/Popover.css index 0a543e7d00e1..b0769d23b30c 100644 --- a/packages/main/src/themes/Popover.css +++ b/packages/main/src/themes/Popover.css @@ -90,3 +90,73 @@ :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: 10; +} + +.ui5-popover-resize-handle [ui5-icon] { + position: absolute; + width: 1rem; + height: 1rem; + cursor: inherit; + color: var(--sapButton_Lite_TextColor); +} + +.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; + transform: rotate(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; + transform: rotate(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; + transform: rotate(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; +} diff --git a/packages/main/test/pages/PopoverResize.html b/packages/main/test/pages/PopoverResize.html new file mode 100644 index 000000000000..35c8d47605bd --- /dev/null +++ b/packages/main/test/pages/PopoverResize.html @@ -0,0 +1,95 @@ + + + + + + + Popover + + + + + + + + +
+ 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..f98922a1059b --- /dev/null +++ b/packages/main/test/pages/styles/PopoverResize.css @@ -0,0 +1,25 @@ +.pageContainer { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; +} + +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