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", () => {