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:
5
.changeset/FN-6377-tablet-resizable-modals.md
Normal file
5
.changeset/FN-6377-tablet-resizable-modals.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Make dashboard modals touch-resizable on tablet and widen the task-detail modal default tablet width.
|
||||
@@ -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`.
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user