diff --git a/components/paper-dialog.stories.tsx b/components/paper-dialog.stories.tsx index 7ddb7d737..ff5e96c3e 100644 --- a/components/paper-dialog.stories.tsx +++ b/components/paper-dialog.stories.tsx @@ -63,6 +63,54 @@ You can override the default control icons (e.g. close and restore) by passing c /> \`\`\` + + +### 📏 Resizable + +PaperDialog can be resized on both desktop and mobile devices. + +Desktop: +- Drag the resize handle on the dialog edge to adjust the width. + +Mobile: +- Drag the top indicator to adjust the height. + +You can enable resizing with a boolean value: + +\`\`\`tsx + +\`\`\` + + +Or configure resize constraints: + +\`\`\`tsx + +\`\`\` + +### 📐 Resize Callbacks + +Use \`onResize\` to react to size changes while dragging and \`onResizeComplete\` to react when resizing has finished: + +\`\`\`tsx + { + console.log(width, height); + }} + onResizeComplete={({ width, height }) => { + console.log("Final size:", width, height); + }} +/> +\`\`\` + ### 📝 Notes - Use \`styles\` prop to override default styles. - Use the \`icons\` prop to override default icons. @@ -207,7 +255,7 @@ export const Default: Story = { @@ -992,6 +1042,7 @@ export const Nested: Story = { void; + onResizeComplete?: (size: { width?: number; height?: number }) => void; +} + +export interface PaperDialogResizable { + minWidth?: string; + maxWidth?: string; + minHeight?: string; + maxHeight?: string; } export interface PaperDialogIcons { @@ -112,6 +123,9 @@ const PaperDialog = forwardRef( controls = ["minimize", closable ? "close" : ""], width, height, + resizable: _resizable = false, + onResize, + onResizeComplete, }, ref ) => { @@ -119,13 +133,177 @@ const PaperDialog = forwardRef( const paperDialogTheme = currentTheme.paperDialog; const dragControls = useDragControls(); + const resizable = resolveResizable(_resizable); + const [showTitlebar, setShowTitlebar] = useState(false); const [dialogState, setDialogState] = useState("closed"); + const [resizeWidth, setResizeWidth] = useState(null); + const [resizeHeight, setResizeHeight] = useState(null); + + const isResizingDesktop = useRef(false); + const isResizingMobile = useRef(false); + const resizeStartX = useRef(0); + const resizeStartWidth = useRef(0); + const resizeStartY = useRef(0); + const resizeStartHeight = useRef(0); + + // Track last pointer Y and timestamp for velocity-based minimize on mobile resize + const lastPointerY = useRef(0); + const lastPointerTime = useRef(0); + const velocityRef = useRef(0); + + const dialogRef = useRef(null); + + const isLeft = position === "left"; + + const handleDesktopResizePointerDown = useCallback( + (e: React.PointerEvent) => { + if (!resizable || mobile) return; + e.preventDefault(); + e.stopPropagation(); + isResizingDesktop.current = true; + resizeStartX.current = e.clientX; + resizeStartWidth.current = + dialogRef.current?.getBoundingClientRect().width ?? + resizeWidth ?? + window.innerWidth * 0.92; + + const minPx = parsePx(resizable.minWidth); + const maxPx = parsePx(resizable.maxWidth); + + const onMove = (ev: PointerEvent) => { + if (!isResizingDesktop.current) return; + const delta = isLeft + ? ev.clientX - resizeStartX.current + : resizeStartX.current - ev.clientX; + const next = Math.min( + maxPx, + Math.max(minPx, resizeStartWidth.current + delta) + ); + setResizeWidth(next); + onResize?.({ width: next }); + }; + + const onUp = () => { + isResizingDesktop.current = false; + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + const finalWidth = resizeWidth ?? resizeStartWidth.current; + onResizeComplete?.({ width: finalWidth }); + }; + + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + }, + [ + resizable, + mobile, + isLeft, + resizable, + resizeWidth, + setResizeWidth, + onResize, + onResizeComplete, + ] + ); + + const handleMobileResizePointerDown = useCallback( + (e: React.PointerEvent) => { + if (!resizable || !mobile) return; + e.preventDefault(); + e.stopPropagation(); + isResizingMobile.current = true; + resizeStartY.current = e.clientY; + resizeStartHeight.current = + dialogRef.current?.getBoundingClientRect().height ?? + resizeHeight ?? + window.innerHeight * 0.88; + + lastPointerY.current = e.clientY; + lastPointerTime.current = performance.now(); + + const maxPx = parsePx(resizable.maxHeight); + const minPx = parsePx(resizable.minHeight); + + const onMove = (ev: PointerEvent) => { + if (!isResizingMobile.current) return; + const delta = resizeStartY.current - ev.clientY; + const DAMPING = 0.72; + + const next = Math.min( + maxPx, + Math.max(minPx, resizeStartHeight.current + delta * DAMPING) + ); + + setResizeHeight(next); + onResize?.({ height: next }); + + const now = performance.now(); + + const dy = ev.clientY - lastPointerY.current; + const dt = now - lastPointerTime.current; + + if (dt > 0) { + velocityRef.current = dy / dt; + } + + lastPointerY.current = ev.clientY; + lastPointerTime.current = performance.now(); + }; + + const onUp = (ev: PointerEvent) => { + isResizingMobile.current = false; + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + + // Compute instantaneous velocity (px/ms, positive = downward) + const dt = performance.now() - lastPointerTime.current; + const velocity = + dt > 0 ? (ev.clientY - lastPointerY.current) / dt : 0; + + if (velocityRef.current > 0.5) { + setDialogState("minimized"); + setShowTitlebar(true); + setTimeout(() => { + setResizeHeight(null); + }, 300); + } else { + const finalHeight = + dialogRef.current?.getBoundingClientRect().height ?? + resizeHeight ?? + resizeStartHeight.current; + onResizeComplete?.({ height: finalHeight }); + } + }; + + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + }, + [ + resizable, + mobile, + resizable, + resizeHeight, + setResizeHeight, + onResize, + onResizeComplete, + lastPointerTime, + lastPointerY, + velocityRef, + ] + ); + + const resolvedWidth = resizeWidth != null ? `${resizeWidth}px` : width; + const resolvedHeight = resizeHeight != null ? `${resizeHeight}px` : height; + const closeDialog = useCallback( async (withTimeout: boolean = true) => { const close = async () => await setDialogState("closed"); + await setResizeHeight(null); + await setResizeWidth(null); + if (mobile) { await setDialogState("minimized"); @@ -155,8 +333,6 @@ const PaperDialog = forwardRef( }, })); - const isLeft = position === "left"; - const handleEscape = useCallback( (e: KeyboardEvent) => { if ( @@ -196,9 +372,7 @@ const PaperDialog = forwardRef( await close(); } }} - styles={{ - self: styles?.overlayStyle, - }} + styles={{ self: styles?.overlayStyle }} show={dialogState === "restored"} /> )} @@ -260,10 +434,11 @@ const PaperDialog = forwardRef( )} ( dragDirectionLock dragControls={dragControls} dragConstraints={{ top: 0, bottom: 0 }} - dragElastic={{ - top: 0, - bottom: 0.6, - }} + dragElastic={{ top: 0, bottom: 0.6 }} onDragEnd={(_, info) => { if (info.offset.y > 120 || info.velocity.y > 500) { setDialogState("minimized"); setShowTitlebar(true); } }} - whileDrag={{ - cursor: "grabbing", - userSelect: "none", - }} + whileDrag={{ cursor: "grabbing", userSelect: "none" }} > + {resizable && !mobile && ( + + )} + {closable && controls?.includes("close") && ( ( )} - {mobile && closable && ( + {mobile && ( dragControls.start(e)} + $resizable={!!resizable} + onPointerDown={(e) => { + if (resizable) { + handleMobileResizePointerDown(e); + } else if (closable) { + dragControls.start(e); + } + }} > - + )} ( } ); +/** + * Convert a CSS size string to pixels at the current viewport. + * Handles: px | dvw | vw | dvh | vh — falls back to parseInt for bare numbers. + */ +function parsePx(value: string): number { + const n = parseFloat(value); + if (value.endsWith("dvw") || value.endsWith("vw")) + return (n / 100) * window.innerWidth; + if (value.endsWith("dvh") || value.endsWith("vh")) + return (n / 100) * window.innerHeight; + return n; +} + +/** Resolve the `resizable` prop into a normalised config (or null when disabled). */ +function resolveResizable( + resizable: boolean | PaperDialogResizable | undefined +): Required | null { + if (!resizable) return null; + const defaults: Required = { + minWidth: "20dvw", + maxWidth: "90dvw", + minHeight: "20dvh", + maxHeight: "90dvh", + }; + if (resizable === true) return defaults; + return { ...defaults, ...resizable }; +} + const DialogOverlay = styled.div<{ $dialogState: PaperDialogState; $style?: CSSProp; @@ -502,6 +715,32 @@ const MotionDialog = styled(motion.div)<{ ${({ $style }) => $style}; `; +const DesktopResizeHandle = styled.div<{ $isLeft: boolean }>` + position: absolute; + top: 0; + bottom: 0; + width: 6px; + z-index: 10000; + cursor: col-resize; + transition: background-color 0.15s ease; + + ${({ $isLeft }) => + $isLeft + ? css` + right: 0; + border-right: 2px solid transparent; + ` + : css` + left: 0; + border-left: 2px solid transparent; + `} + + &:hover, + &:active { + background-color: rgba(128, 128, 128, 0.18); + } +`; + const ActionButtonWrapper = styled.div<{ $isLeft: boolean; $top: number; @@ -628,6 +867,7 @@ const PaperDialogContent = styled.div<{ const DragIndicatorWrapper = styled(motion.div)<{ $theme?: PaperDialogThemeConfig; $style?: CSSProp; + $resizable?: boolean; }>` *, ::before, @@ -640,8 +880,9 @@ const DragIndicatorWrapper = styled(motion.div)<{ display: flex; top: 0; justify-content: center; - width: 100%; - cursor: grab; + + cursor: ${({ $resizable }) => ($resizable ? "ns-resize" : "grab")}; + width: 100dvw; height: 60px; z-index: 9992999; align-items: center; @@ -649,7 +890,7 @@ const DragIndicatorWrapper = styled(motion.div)<{ background-color: ${({ $theme }) => $theme?.backgroundColor}; &:active { - cursor: grabbing; + cursor: ${({ $resizable }) => ($resizable ? "ns-resize" : "grabbing")}; } ${({ $style }) => $style} @@ -657,13 +898,17 @@ const DragIndicatorWrapper = styled(motion.div)<{ const DragIndicator = styled(motion.div)<{ $theme?: PaperDialogThemeConfig; + $resizable?: boolean; }>` display: flex; width: 48px; height: 5px; border-radius: 999px; background-color: ${({ $theme }) => $theme?.textColor}; - opacity: 0.3; + opacity: ${({ $resizable }) => ($resizable ? 0.5 : 0.3)}; + transition: + opacity 0.2s ease, + width 0.2s ease; `; const MiniTitleBar = styled(motion.div)<{ diff --git a/test/component/paper-dialog.cy.tsx b/test/component/paper-dialog.cy.tsx index 55869b31b..6734015bf 100644 --- a/test/component/paper-dialog.cy.tsx +++ b/test/component/paper-dialog.cy.tsx @@ -101,6 +101,192 @@ describe("PaperDialog", () => { }); }); + context("resizable", () => { + context("object", () => { + context("minWidth", () => { + it("does not resize below the minimum width", () => { + cy.viewport(500, 700); + + cy.mount( + + ); + + cy.findAllByRole("button").eq(0).click(); + cy.wait(500); + + cy.findByLabelText("paper-dialog-resize-handle") + .realMouseDown({ position: "center" }) + .realMouseMove(150, 0) + .realMouseUp(); + + cy.findByLabelText("paper-dialog-wrapper") + .invoke("width") + .should("be.closeTo", 200, 5); + }); + }); + + context("maxWidth", () => { + it("does not resize above the maximum width", () => { + cy.viewport(500, 700); + + cy.mount( + + ); + + cy.findAllByRole("button").eq(0).click(); + cy.wait(500); + + cy.findByLabelText("paper-dialog-resize-handle") + .realMouseDown({ position: "center" }) + .realMouseMove(-150, 0) + .realMouseUp(); + + cy.findByLabelText("paper-dialog-wrapper") + .invoke("width") + .should("be.closeTo", 300, 5); + }); + }); + }); + + context("when resizing wider", () => { + it("renders a wider dialog", () => { + cy.viewport(500, 700); + + cy.mount(); + + cy.findAllByRole("button").eq(0).should("exist").click(); + + cy.wait(500); + + let initialWidth: number; + + cy.findByLabelText("paper-dialog-wrapper") + .invoke("width") + .then((w) => { + initialWidth = w as number; + }); + + cy.findByLabelText("paper-dialog-resize-handle") + .realMouseDown({ position: "center" }) + .realMouseMove(-150, 0) + .realMouseUp(); + + cy.findByLabelText("paper-dialog-wrapper") + .invoke("width") + .then((w) => { + expect(w).to.be.closeTo(initialWidth + 150, 5); + }); + }); + }); + + context("when resizing narrower", () => { + it("renders a narrower dialog", () => { + cy.viewport(500, 700); + + cy.mount(); + + cy.findAllByRole("button").eq(0).should("exist").click(); + + cy.wait(500); + + let initialWidth: number; + + cy.findByLabelText("paper-dialog-wrapper") + .invoke("width") + .then((w) => { + initialWidth = w as number; + }); + + cy.findByLabelText("paper-dialog-resize-handle") + .realMouseDown({ position: "center" }) + .realMouseMove(150, 0) + .realMouseUp(); + + cy.findByLabelText("paper-dialog-wrapper") + .invoke("width") + .then((w) => { + expect(w).to.be.closeTo(initialWidth - 150, 5); + }); + }); + }); + + context("onResize", () => { + it("should shows the width", () => { + cy.viewport(500, 700); + + const onResize = cy.spy().as("onResize"); + + cy.mount(); + + cy.findAllByRole("button").eq(0).should("exist").click(); + + cy.wait(500); + cy.get("@onResize").should("not.have.been.called"); + + cy.findByLabelText("paper-dialog-wrapper").should("be.visible"); + + cy.findByLabelText("paper-dialog-resize-handle") + .realMouseDown({ position: "center" }) + .realMouseMove(-50, 0) + .wait(200) + .realMouseMove(-100, 0) + .wait(200) + .realMouseMove(-150, 0) + .realMouseUp(); + + cy.wait(300); + + cy.get("@onResize") + .its("lastCall.args.0.width") + .should("be.a", "number"); + }); + }); + + context("onResizeComplete", () => { + it("should call onResizeComplete with final width", () => { + cy.viewport(500, 700); + + const onResizeComplete = cy.spy().as("onResizeComplete"); + + cy.mount( + + ); + + cy.findAllByRole("button").eq(0).click(); + + cy.wait(500); + + cy.get("@onResizeComplete").should("not.have.been.called"); + + cy.findByLabelText("paper-dialog-resize-handle") + .realMouseDown({ position: "center" }) + .realMouseMove(-50, 0) + .wait(200) + .realMouseMove(-100, 0) + .wait(200) + .realMouseMove(-150, 0) + .realMouseUp(); + + cy.wait(300); + + cy.get("@onResizeComplete") + .should("have.been.calledOnce") + .its("lastCall.args.0.width") + .should("be.a", "number"); + }); + }); + }); + context("mobile", () => { it("renders with radius 0.75rem, width 100dvw and height 72dvh", () => { cy.viewport(500, 700); @@ -116,6 +302,215 @@ describe("PaperDialog", () => { .and("have.css", "height", "616px"); }); + context("resizable", () => { + context("object", () => { + context("minHeight", () => { + it("does not resize below the minimum height", () => { + cy.viewport(500, 700); + + cy.mount( + + ); + + cy.findAllByRole("button").eq(0).click(); + cy.wait(500); + + cy.findByLabelText("paper-dialog-drag-indicator") + .realMouseDown({ position: "center" }) + .realMouseMove(0, 50) + .wait(200) + .realMouseMove(0, 100) + .wait(200) + .realMouseMove(0, 150) + .wait(200) + .realMouseMove(0, 250) + .realMouseUp(); + + cy.findByLabelText("paper-dialog-wrapper") + .invoke("height") + .should("be.closeTo", 200, 10); + }); + }); + + context("maxHeight", () => { + it("does not resize above the maximum height", () => { + cy.viewport(500, 700); + + cy.mount( + + ); + + cy.findAllByRole("button").eq(0).click(); + cy.wait(500); + + cy.findByLabelText("paper-dialog-drag-indicator") + .realMouseDown({ position: "center" }) + .realMouseMove(0, -50) + .wait(200) + .realMouseMove(0, -100) + .wait(200) + .realMouseMove(0, -150) + .wait(200) + .realMouseMove(0, -250) + .realMouseUp(); + + cy.findByLabelText("paper-dialog-wrapper") + .invoke("height") + .should("be.closeTo", 400, 10); + }); + }); + }); + + context("onResize", () => { + it("should shows the height ", () => { + cy.viewport(500, 700); + + const onResize = cy.spy().as("onResize"); + + cy.mount( + + ); + + cy.findAllByRole("button").eq(0).should("exist").click(); + + cy.wait(500); + cy.get("@onResize").should("not.have.been.called"); + + cy.findByLabelText("paper-dialog-wrapper").should("be.visible"); + + cy.findByLabelText("paper-dialog-drag-indicator") + .realMouseDown({ position: "center" }) + .realMouseMove(0, 30) + .wait(200) + .realMouseMove(0, 60) + .wait(200) + .realMouseMove(0, 100) + .realMouseUp(); + + cy.wait(300); + + cy.get("@onResize") + .its("lastCall.args.0.height") + .should("be.a", "number"); + }); + }); + + context("onResizeComplete", () => { + it("should call onResizeComplete with final height", () => { + cy.viewport(500, 700); + + const onResizeComplete = cy.spy().as("onResizeComplete"); + + cy.mount( + + ); + + cy.findAllByRole("button").eq(0).click(); + + cy.wait(500); + + cy.get("@onResizeComplete").should("not.have.been.called"); + + cy.findByLabelText("paper-dialog-drag-indicator") + .realMouseDown({ position: "center" }) + .realMouseMove(0, 30) + .wait(200) + .realMouseMove(0, 60) + .wait(200) + .realMouseMove(0, 100) + .realMouseUp(); + + cy.wait(300); + + cy.get("@onResizeComplete") + .should("have.been.calledOnce") + .its("lastCall.args.0.height") + .should("be.a", "number"); + }); + }); + + context("when dragged fast", () => { + it("should minimize the dialog", () => { + cy.viewport(500, 700); + cy.mount(); + + cy.findAllByRole("button").eq(0).should("exist").click(); + + cy.wait(500); + + cy.findByLabelText("paper-dialog-wrapper").should("be.visible"); + + cy.findByLabelText("paper-dialog-drag-indicator") + .realMouseDown({ position: "center" }) + .realMouseMove(0, 300) + .realMouseUp(); + + cy.wait(300); + cy.findByLabelText("paper-dialog-wrapper").should("not.be.visible"); + }); + }); + + context("when dragged slowly", () => { + it("should not minimize and resize the dialog", () => { + cy.viewport(500, 700); + cy.mount(); + + cy.findAllByRole("button").eq(0).should("exist").click(); + cy.wait(300); + let initialHeight: number; + + cy.findByLabelText("paper-dialog-wrapper") + .should("be.visible") + .then(($el) => { + initialHeight = $el.height()!; + }); + + cy.findByLabelText("paper-dialog-drag-indicator") + .realMouseDown({ position: "center" }) + .realMouseMove(0, 30) + .wait(200) + .realMouseMove(0, 60) + .wait(200) + .realMouseMove(0, 100) + .realMouseUp(); + + cy.wait(300); + + cy.findByLabelText("paper-dialog-wrapper") + .should("be.visible") + .then(($el) => { + const resizedHeight = $el.height()!; + + expect(resizedHeight).to.be.lessThan(initialHeight); + }); + }); + }); + }); + context("user selection", () => { it("renders action buttons without text selection", () => { cy.viewport(500, 700); @@ -165,7 +560,8 @@ describe("PaperDialog", () => { context("drag behavior", () => { context("when dragging icon drag indicator", () => { it("should close the dialog", () => { - cy.viewport(500, 500); + cy.viewport(500, 700); + cy.mount(); cy.findAllByRole("button").eq(0).should("exist").click(); @@ -185,16 +581,17 @@ describe("PaperDialog", () => { context("when dragging in area empty icon drag indicator", () => { it("should close the dialog", () => { - cy.viewport(500, 500); + cy.viewport(500, 700); + cy.mount(); cy.findAllByRole("button").eq(0).should("exist").click(); cy.wait(500); - cy.findByLabelText("paper-dialog-content") + cy.findByLabelText("paper-dialog-drag-indicator") .should("exist") - .realMouseDown({ position: "topRight" }) + .realMouseDown({ position: "right" }) .realMouseMove(0, 350) .realMouseUp(); @@ -306,7 +703,7 @@ describe("PaperDialog", () => { cy.wait(300); cy.findByLabelText("paper-dialog-close-icon").should("not.exist"); - cy.findByLabelText("paper-dialog-drag-indicator").should("not.exist"); + cy.findByLabelText("paper-dialog-drag-indicator").should("exist"); }); context("when clicking overlay-background", () => {