Files
fusion/packages/dashboard/app/hooks/useModalResizePersist.ts
gsxdsm eb607c6ffd FN-6377: make tablet modals touch-resizable
Enable shared resize handles for modals on tablet and desktop while keeping mobile sheets full-screen.

- Add a pointer-driven resize grip to useModalResizePersist with debounced persistence and mobile cleanup.
- Widen the task-detail modal tablet default to 96vw / 1024px.
- Cover resize behavior and tablet width expectations with dashboard tests.
- Document the shared modal resize pattern and add the published package changeset.

Files changed:
 .changeset/FN-6377-tablet-resizable-modals.md      |   5 +
 docs/dashboard-guide.md                            |   2 +-
 .../task-detail-modal-tablet-width.test.ts         |   4 +-
 .../dashboard/app/components/TaskDetailModal.css   |   4 +-
 .../hooks/__tests__/useModalResizePersist.test.tsx | 218 +++++++++++++++++++++
 .../dashboard/app/hooks/useModalResizePersist.ts   | 140 ++++++++++---
 packages/dashboard/app/styles.css                  |  36 ++++
 7 files changed, 382 insertions(+), 27 deletions(-)

Fusion-Task-Id: FN-6377

Fusion-Task-Lineage: ecc12aa4-f881-4476-a69b-13d34a73e263
2026-06-13 11:46:53 -07:00

190 lines
6.5 KiB
TypeScript

import { useEffect, type RefObject } from "react";
import { isMobileViewport } from "./useViewportMode";
interface PersistedSize {
width?: number;
height?: number;
}
const RESIZE_GRIP_CLASS = "modal-resize-grip";
const RESIZE_GRIP_LABEL = "Resize modal from bottom-right corner";
function readPersistableSize(node: HTMLElement): PersistedSize {
const styleWidth = Number.parseFloat(node.style.width);
const styleHeight = Number.parseFloat(node.style.height);
return {
width: node.offsetWidth > 0
? node.offsetWidth
: Number.isFinite(styleWidth)
? styleWidth
: undefined,
height: node.offsetHeight > 0
? node.offsetHeight
: Number.isFinite(styleHeight)
? styleHeight
: undefined,
};
}
/**
* Persist a resizable modal's user-chosen dimensions across opens.
*
* Pair this with `resize: both` in CSS on the modal element. When the user
* drags the resize grip, the new pixel size is captured via ResizeObserver
* and stored under `storageKey`. On the next open, the stored size is
* replayed as inline `width` / `height` styles before the modal becomes
* interactive.
*
* The CSS `min-*` / `max-*` constraints still clamp the applied size at
* render time, so a value saved on a 4K display won't break the layout
* when reopened on a laptop.
*
* @param ref ref to the resizable modal element
* @param isOpen the modal's open flag — observation only runs while true
* @param storageKey localStorage key, must be stable + unique per modal
*/
export function useModalResizePersist(
ref: RefObject<HTMLElement | null>,
isOpen: boolean,
storageKey: string,
): void {
useEffect(() => {
if (!isOpen) return;
const node = ref.current;
if (!node) return;
// On mobile, modals render full-screen via CSS (height: 100dvh) and the
// resize grip is disabled. Replaying a desktop-saved pixel height here
// would override the mobile CSS and leave the modal stuck at a partial
// height. Skip restoration; also clear any width/height left over from
// a prior desktop render of the same modal instance.
const existingGrip = node.querySelector(`:scope > .${RESIZE_GRIP_CLASS}`);
if (isMobileViewport()) {
node.style.removeProperty("width");
node.style.removeProperty("height");
existingGrip?.remove();
return;
}
// Apply the persisted size on open.
try {
const raw = localStorage.getItem(storageKey);
if (raw) {
const { width, height } = JSON.parse(raw) as PersistedSize;
if (typeof width === "number" && width > 0) node.style.width = `${width}px`;
if (typeof height === "number" && height > 0) node.style.height = `${height}px`;
}
} catch {
// ignore corrupted entry
}
let saveTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleSave = () => {
const { width, height } = readPersistableSize(node);
if (typeof width !== "number" || typeof height !== "number") return;
// Debounce so we don't spam localStorage during the drag.
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
try {
localStorage.setItem(storageKey, JSON.stringify({ width, height }));
} catch {
// quota / private mode — best-effort
}
}, 200);
};
let lastSavedW = node.offsetWidth;
let lastSavedH = node.offsetHeight;
const observer =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(() => {
const w = node.offsetWidth;
const h = node.offsetHeight;
if (w === lastSavedW && h === lastSavedH) return;
lastSavedW = w;
lastSavedH = h;
scheduleSave();
});
observer?.observe(node);
const grip = document.createElement("div");
grip.className = RESIZE_GRIP_CLASS;
grip.setAttribute("role", "separator");
grip.setAttribute("aria-label", RESIZE_GRIP_LABEL);
grip.dataset.resizeDirection = "se";
existingGrip?.remove();
node.appendChild(grip);
let cleanupActiveDrag: (() => void) | null = null;
const onPointerDown = (event: PointerEvent) => {
event.preventDefault();
event.stopPropagation();
if (typeof grip.setPointerCapture === "function") {
grip.setPointerCapture(event.pointerId);
}
const startRect = node.getBoundingClientRect();
const startWidth = startRect.width ||
node.offsetWidth ||
Number.parseFloat(node.style.width) ||
0;
const startHeight = startRect.height ||
node.offsetHeight ||
Number.parseFloat(node.style.height) ||
0;
const startX = event.clientX;
const startY = event.clientY;
const previousUserSelect = document.body.style.userSelect;
document.body.style.userSelect = "none";
const onPointerMove = (moveEvent: PointerEvent) => {
moveEvent.preventDefault();
const nextWidth = startWidth + moveEvent.clientX - startX;
const nextHeight = startHeight + moveEvent.clientY - startY;
if (nextWidth > 0) node.style.width = `${nextWidth}px`;
if (nextHeight > 0) node.style.height = `${nextHeight}px`;
scheduleSave();
};
const endDrag = (upEvent: PointerEvent) => {
if (typeof grip.releasePointerCapture === "function") {
grip.releasePointerCapture(upEvent.pointerId);
}
document.body.style.userSelect = previousUserSelect;
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", endDrag);
document.removeEventListener("pointercancel", endDrag);
scheduleSave();
cleanupActiveDrag = null;
};
cleanupActiveDrag = () => {
document.body.style.userSelect = previousUserSelect;
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", endDrag);
document.removeEventListener("pointercancel", endDrag);
};
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerup", endDrag);
document.addEventListener("pointercancel", endDrag);
};
grip.addEventListener("pointerdown", onPointerDown);
return () => {
cleanupActiveDrag?.();
grip.removeEventListener("pointerdown", onPointerDown);
grip.remove();
observer?.disconnect();
if (saveTimer) clearTimeout(saveTimer);
};
}, [ref, isOpen, storageKey]);
}