From eb607c6ffd51530ab7b943280b88d281bbeb7f61 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 13 Jun 2026 11:46:31 -0700 Subject: [PATCH] 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 --- .changeset/FN-6377-tablet-resizable-modals.md | 5 + docs/dashboard-guide.md | 2 +- .../task-detail-modal-tablet-width.test.ts | 4 +- .../app/components/TaskDetailModal.css | 4 +- .../__tests__/useModalResizePersist.test.tsx | 218 ++++++++++++++++++ .../app/hooks/useModalResizePersist.ts | 140 +++++++++-- packages/dashboard/app/styles.css | 36 +++ 7 files changed, 382 insertions(+), 27 deletions(-) create mode 100644 .changeset/FN-6377-tablet-resizable-modals.md create mode 100644 packages/dashboard/app/hooks/__tests__/useModalResizePersist.test.tsx diff --git a/.changeset/FN-6377-tablet-resizable-modals.md b/.changeset/FN-6377-tablet-resizable-modals.md new file mode 100644 index 0000000000..8a733048eb --- /dev/null +++ b/.changeset/FN-6377-tablet-resizable-modals.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Make dashboard modals touch-resizable on tablet and widen the task-detail modal default tablet width. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index fec60e2a90..0befeb74ee 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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`. diff --git a/packages/dashboard/app/__tests__/task-detail-modal-tablet-width.test.ts b/packages/dashboard/app/__tests__/task-detail-modal-tablet-width.test.ts index 382354ef58..9d3486e9ab 100644 --- a/packages/dashboard/app/__tests__/task-detail-modal-tablet-width.test.ts +++ b/packages/dashboard/app/__tests__/task-detail-modal-tablet-width.test.ts @@ -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", () => { diff --git a/packages/dashboard/app/components/TaskDetailModal.css b/packages/dashboard/app/components/TaskDetailModal.css index b049e566e6..2a9a35a369 100644 --- a/packages/dashboard/app/components/TaskDetailModal.css +++ b/packages/dashboard/app/components/TaskDetailModal.css @@ -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); } diff --git a/packages/dashboard/app/hooks/__tests__/useModalResizePersist.test.tsx b/packages/dashboard/app/hooks/__tests__/useModalResizePersist.test.tsx new file mode 100644 index 0000000000..902b06eb28 --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useModalResizePersist.test.tsx @@ -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[0]; + +const resizeObserverCallbacks = new Set(); + +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(null); + useModalResizePersist(modalRef, isOpen, storageKey); + + return ( +
+ ); +} + +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(); + + 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(); + 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(); + 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(); + + 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(); + + 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(); + expect(modal.querySelector(".modal-resize-grip")).toBeTruthy(); + unmount(); + expect(modal.querySelector(".modal-resize-grip")).toBeNull(); + }); +}); diff --git a/packages/dashboard/app/hooks/useModalResizePersist.ts b/packages/dashboard/app/hooks/useModalResizePersist.ts index ed6ed55cb5..5417bcd872 100644 --- a/packages/dashboard/app/hooks/useModalResizePersist.ts +++ b/packages/dashboard/app/hooks/useModalResizePersist.ts @@ -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 | 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]); diff --git a/packages/dashboard/app/styles.css b/packages/dashboard/app/styles.css index ca5015abf7..1fd885343c 100644 --- a/packages/dashboard/app/styles.css +++ b/packages/dashboard/app/styles.css @@ -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;