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
190 lines
6.5 KiB
TypeScript
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]);
|
|
}
|