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
This commit is contained in:
gsxdsm
2026-06-13 11:46:31 -07:00
parent 97a49ac196
commit eb607c6ffd
7 changed files with 382 additions and 27 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Make dashboard modals touch-resizable on tablet and widen the task-detail modal default tablet width.

View File

@@ -1190,7 +1190,7 @@ Dark/light modes via `data-theme`; 54 color themes via `data-color-theme` (lazy-
Reuse existing primitives from `styles.css`:
- **Buttons**: `.btn`, `.btn-primary`, `.btn-danger`, `.btn-warning`, `.btn-sm`, `.btn-icon`, `.btn-icon--active`, `.btn-badge`. All inherit `:focus-visible` via `--focus-ring-strong` and `:active` via `transform: scale(0.97)`.
- **Modals**: `.modal-overlay[.open]`, `.modal`, `.modal-lg`, `.modal-header`, `.modal-close`, `.modal-actions`, `.modal-actions-left/right`. Overlay pads top with `--overlay-padding-top`. Overlay dialogs should render through `createPortal(..., document.body)` so `position: fixed` overlays escape transformed, contained, or fixed ancestors.
- **Modals**: `.modal-overlay[.open]`, `.modal`, `.modal-lg`, `.modal-header`, `.modal-close`, `.modal-actions`, `.modal-actions-left/right`. Overlay pads top with `--overlay-padding-top`. Overlay dialogs should render through `createPortal(..., document.body)` so `position: fixed` overlays escape transformed, contained, or fixed ancestors. Resizable modals using `useModalResizePersist(...)` get a shared bottom-right touch/mouse resize grip on tablet and desktop; mobile sheets stay full-screen and grip-free.
- **Forms**: `.form-group`, `.input`, `.select`, `.checkbox-label`, `.form-error`. Inputs in `.form-group` get focus styles automatically.
- **Cards**: `.card`, `.card-header`, `.card-id`, `.card-title`, `.card-meta`, `.card-status-badge--{triage,todo,in-progress,in-review,done,archived}`.
- **Utility**: `.touch-target` (44px min), `.visually-hidden`.

View File

@@ -23,8 +23,8 @@ describe("task detail modal tablet width (FN-5599)", () => {
const tabletBlock = tabletBlockMatch![1];
const modalRuleMatch = tabletBlock.match(/\.modal\.task-detail-modal\s*\{[^}]*\}/s);
expect(modalRuleMatch).toBeTruthy();
expect(modalRuleMatch![0]).toContain("width: min(92vw, 960px);");
expect(modalRuleMatch![0]).toContain("max-width: 92vw;");
expect(modalRuleMatch![0]).toContain("width: min(96vw, 1024px);");
expect(modalRuleMatch![0]).toContain("max-width: 96vw;");
});
it("keeps mobile full-screen sheet width behavior", () => {

View File

@@ -939,8 +939,8 @@
/* FN-5599: widen task detail modal on tablet viewports. */
@media (min-width: 769px) and (max-width: 1024px) {
.modal.task-detail-modal {
width: min(92vw, 960px);
max-width: 92vw;
width: min(96vw, 1024px);
max-width: 96vw;
height: 92vh;
max-height: calc(100dvh - var(--overlay-padding-top, 6vh) - 16px);
}

View File

@@ -0,0 +1,218 @@
import { render, screen } from "@testing-library/react";
import { useRef } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useModalResizePersist } from "../useModalResizePersist";
const STORAGE_KEY = "fusion:test-modal-size";
type ResizeObserverCallback = ConstructorParameters<typeof ResizeObserver>[0];
const resizeObserverCallbacks = new Set<ResizeObserverCallback>();
class MockResizeObserver implements ResizeObserver {
readonly callback: ResizeObserverCallback;
constructor(callback: ResizeObserverCallback) {
this.callback = callback;
resizeObserverCallbacks.add(callback);
}
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn(() => {
resizeObserverCallbacks.delete(this.callback);
});
}
function setViewport(width: number, height = 800): void {
Object.defineProperty(window, "innerWidth", { configurable: true, value: width });
Object.defineProperty(window, "innerHeight", { configurable: true, value: height });
Object.defineProperty(window, "screen", {
configurable: true,
value: { width, height },
});
window.matchMedia = vi.fn((query: string) => ({
matches: query.includes("max-width: 768px")
? width <= 768
: query.includes("max-height: 480px")
? height <= 480
: query.includes("min-width: 769px") && query.includes("max-width: 1024px")
? width >= 769 && width <= 1024
: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})) as typeof window.matchMedia;
}
function dispatchPointerEvent(
target: EventTarget,
type: string,
init: { clientX: number; clientY: number; pointerId?: number; pointerType?: string },
): void {
const event = new Event(type, { bubbles: true, cancelable: true }) as PointerEvent;
Object.defineProperties(event, {
clientX: { value: init.clientX },
clientY: { value: init.clientY },
pointerId: { value: init.pointerId ?? 1 },
pointerType: { value: init.pointerType ?? "touch" },
});
target.dispatchEvent(event);
}
function installModalGeometry(node: HTMLElement, width = 500, height = 400): void {
Object.defineProperty(node, "offsetWidth", {
configurable: true,
get: () => Number.parseFloat(node.style.width) || width,
});
Object.defineProperty(node, "offsetHeight", {
configurable: true,
get: () => Number.parseFloat(node.style.height) || height,
});
node.getBoundingClientRect = vi.fn(() => ({
x: 0,
y: 0,
top: 0,
left: 0,
right: node.offsetWidth,
bottom: node.offsetHeight,
width: node.offsetWidth,
height: node.offsetHeight,
toJSON: () => ({}),
}));
}
function triggerResizeObservers(): void {
for (const callback of resizeObserverCallbacks) {
callback([], {} as ResizeObserver);
}
}
function Harness({
initialHeight,
initialWidth,
isOpen = true,
storageKey = STORAGE_KEY,
}: {
initialHeight?: string;
initialWidth?: string;
isOpen?: boolean;
storageKey?: string;
}) {
const modalRef = useRef<HTMLDivElement | null>(null);
useModalResizePersist(modalRef, isOpen, storageKey);
return (
<div
data-testid="modal"
ref={modalRef}
className="modal"
style={{ width: initialWidth, height: initialHeight }}
/>
);
}
describe("useModalResizePersist", () => {
beforeEach(() => {
vi.useFakeTimers();
localStorage.clear();
resizeObserverCallbacks.clear();
vi.stubGlobal("ResizeObserver", MockResizeObserver);
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
document.body.style.userSelect = "";
});
it("injects a touch-capable resize grip on tablet and persists dragged size", () => {
setViewport(900);
render(<Harness />);
const modal = screen.getByTestId("modal");
installModalGeometry(modal);
const grip = modal.querySelector(".modal-resize-grip") as HTMLElement;
expect(grip).toBeTruthy();
expect(grip).toHaveAttribute("role", "separator");
expect(grip).toHaveAttribute("aria-label", "Resize modal from bottom-right corner");
dispatchPointerEvent(grip, "pointerdown", { clientX: 10, clientY: 20, pointerType: "touch" });
dispatchPointerEvent(document, "pointermove", { clientX: 70, clientY: 65, pointerType: "touch" });
dispatchPointerEvent(document, "pointerup", { clientX: 70, clientY: 65, pointerType: "touch" });
expect(modal.style.width).toBe("560px");
expect(modal.style.height).toBe("445px");
vi.advanceTimersByTime(200);
expect(JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}"))
.toEqual({ width: 560, height: 445 });
});
it("keeps desktop grip and native ResizeObserver persistence/restore behavior", () => {
setViewport(1280);
localStorage.setItem(STORAGE_KEY, JSON.stringify({ width: 610, height: 480 }));
render(<Harness />);
const modal = screen.getByTestId("modal");
installModalGeometry(modal);
expect(modal.querySelector(".modal-resize-grip")).toBeTruthy();
expect(modal.style.width).toBe("610px");
expect(modal.style.height).toBe("480px");
modal.style.width = "640px";
modal.style.height = "500px";
triggerResizeObservers();
vi.advanceTimersByTime(200);
expect(JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}"))
.toEqual({ width: 640, height: 500 });
});
it("clears inline size and does not inject a grip on mobile", () => {
setViewport(700);
localStorage.setItem(STORAGE_KEY, JSON.stringify({ width: 610, height: 480 }));
render(<Harness initialWidth="610px" initialHeight="480px" />);
const mobileModal = screen.getByTestId("modal");
expect(mobileModal.querySelector(".modal-resize-grip")).toBeNull();
expect(mobileModal.style.width).toBe("");
expect(mobileModal.style.height).toBe("");
});
it("removes the grip and drag listeners when closed or unmounted", () => {
setViewport(900);
const removeSpy = vi.spyOn(document, "removeEventListener");
const { rerender, unmount } = render(<Harness isOpen />);
const modal = screen.getByTestId("modal");
installModalGeometry(modal);
const grip = modal.querySelector(".modal-resize-grip") as HTMLElement;
expect(grip).toBeTruthy();
dispatchPointerEvent(grip, "pointerdown", { clientX: 10, clientY: 20 });
rerender(<Harness isOpen={false} />);
expect(modal.querySelector(".modal-resize-grip")).toBeNull();
expect(removeSpy).toHaveBeenCalledWith("pointermove", expect.any(Function));
expect(removeSpy).toHaveBeenCalledWith("pointerup", expect.any(Function));
expect(removeSpy).toHaveBeenCalledWith("pointercancel", expect.any(Function));
rerender(<Harness isOpen />);
expect(modal.querySelector(".modal-resize-grip")).toBeTruthy();
unmount();
expect(modal.querySelector(".modal-resize-grip")).toBeNull();
});
});

View File

@@ -1,10 +1,32 @@
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.
*
@@ -37,13 +59,12 @@ export function useModalResizePersist(
// 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 isMobile =
typeof window !== "undefined" &&
("ontouchstart" in window || navigator.maxTouchPoints > 0) &&
window.innerWidth <= 768;
if (isMobile) {
const existingGrip = node.querySelector(`:scope > .${RESIZE_GRIP_CLASS}`);
if (isMobileViewport()) {
node.style.removeProperty("width");
node.style.removeProperty("height");
existingGrip?.remove();
return;
}
@@ -59,34 +80,109 @@ export function useModalResizePersist(
// ignore corrupted entry
}
// jsdom (and very old browsers) lacks ResizeObserver — skip persistence
// gracefully rather than throw. Restoration above still ran.
if (typeof ResizeObserver === "undefined") return;
let lastSavedW = node.offsetWidth;
let lastSavedH = node.offsetHeight;
let saveTimer: ReturnType<typeof setTimeout> | null = null;
const observer = new ResizeObserver(() => {
const w = node.offsetWidth;
const h = node.offsetHeight;
if (w === lastSavedW && h === lastSavedH) return;
lastSavedW = w;
lastSavedH = h;
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: w, height: h }));
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);
observer.observe(node);
return () => {
observer.disconnect();
cleanupActiveDrag?.();
grip.removeEventListener("pointerdown", onPointerDown);
grip.remove();
observer?.disconnect();
if (saveTimer) clearTimeout(saveTimer);
};
}, [ref, isOpen, storageKey]);

View File

@@ -1143,6 +1143,7 @@ body {
}
.modal {
position: relative;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
@@ -1152,6 +1153,37 @@ body {
display: flex;
flex-direction: column;
}
.modal-resize-grip {
position: absolute;
right: 0;
bottom: 0;
z-index: 2;
width: var(--space-lg);
height: var(--space-lg);
cursor: se-resize;
touch-action: none;
background: transparent;
}
.modal-resize-grip::after {
content: "";
position: absolute;
right: var(--space-xs);
bottom: var(--space-xs);
width: var(--space-md);
height: var(--space-md);
border-right: var(--btn-border-width) solid var(--border);
border-bottom: var(--btn-border-width) solid var(--border);
opacity: 0;
transition: opacity var(--transition-fast);
}
.modal-resize-grip:hover::after,
.modal-resize-grip:focus-visible::after,
.modal-resize-grip:active::after {
opacity: 1;
}
.modal-lg {
width: 640px;
}
@@ -3433,6 +3465,10 @@ input[type="range"]:focus-visible {
padding-bottom: env(safe-area-inset-bottom, 0px);
}
.modal-resize-grip {
display: none;
}
/* Settings modal: use the section picker as the only mobile navigation */
.settings-layout {
flex-direction: column;