Files
fusion/packages/dashboard/app/hooks/useModalResizePersist.ts
gsxdsm 43160a7aae FN-8621: migrate complex modals to FloatingWindow
Unify complex dashboard modal presentation under persisted FloatingWindow geometry.

- Migrate Create Room, Task Detail, Agent Detail, and GitHub Import modal presentations.
- Preserve documented embedded and docked exceptions, dismissal behavior, and nested scrolling.
- Add presentation-contract coverage and publish dashboard guidance and changesets.

Files changed:
 ...n-8619-resize-persist-modals-floating-window.md |   7 ++
 .changeset/fn-8621-create-room-floating-window.md  |   7 ++
 docs/dashboard-guide.md                            |  16 +++-
 docs/dashboard-modal-inventory.md                  |  12 +--
 .../dashboard/app/components/AgentDetailView.css   |  16 +---
 .../dashboard/app/components/AgentDetailView.tsx   | 102 ++++++++++++++++-----
 .../dashboard/app/components/CreateRoomModal.css   |  19 +++-
 .../dashboard/app/components/CreateRoomModal.tsx   |  57 ++++++++----
 .../dashboard/app/components/FloatingWindow.css    |  13 ++-
 .../dashboard/app/components/FloatingWindow.tsx    |  15 +++
 .../dashboard/app/components/GitHubImportModal.css |  11 +--
 .../dashboard/app/components/GitHubImportModal.tsx |  40 ++++++--
 .../dashboard/app/components/TaskDetailModal.css   |  52 +----------
 .../dashboard/app/components/TaskDetailModal.tsx   |  58 ++++++------
 .../__tests__/AgentDetailView.core.test.tsx        |   2 +-
 .../AgentDetailView.mobile-scroll.test.tsx         |   6 +-
 .../components/__tests__/CreateRoomModal.test.tsx  |  62 +++++++++++--
 .../components/__tests__/FloatingWindow.test.tsx   |   1 +
 .../__tests__/GitHubImportModal.test.tsx           |   8 +-
 ...etailModal.responsive-and-dependencies.test.tsx |  77 +++++++---------
 .../__tests__/modal-presentation-contract.test.tsx |  74 +++++++++++++++
 .../dashboard/app/hooks/useEmbeddedPresentation.ts |   2 +-
 .../dashboard/app/hooks/useModalResizePersist.ts   |   5 +
 23 files changed, 441 insertions(+), 221 deletions(-)

Fusion-Task-Id: FN-8621

Fusion-Task-Lineage: 04b6f3fe-d527-4a21-a0cb-489eb20f5e91

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-26 19:52:32 -07:00

255 lines
9.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";
interface ModalResizePersistOptions {
/** Enable the explicit 44px hit target on the tablet-touch task-detail surface. */
touchTargets?: boolean;
}
/*
FNXC:TaskModalResize 2026-07-24-19:20:
The shared Task Detail grip must offer the same keyboard discovery as floating task windows.
Use the established modal viewport-padding quantum so arrow keys adjust its two dimensions
without creating a second resize scale or bypassing the existing persistence path.
*/
const KEYBOARD_RESIZE_STEP = 16;
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
* @param options tablet-only touch-target opt-in; other shared modal consumers retain desktop geometry
*/
/*
FNXC:ModalTouchGeometry 2026-07-26-19:30:
FN-8619 migrated every product modal consumer to FloatingWindow. Keep this hook, its grip CSS,
and tests because the Chromium touch-geometry e2e fixture still exercises the legacy resize seam.
*/
export function useModalResizePersist(
ref: RefObject<HTMLElement | null>,
isOpen: boolean,
storageKey: string,
options: ModalResizePersistOptions = {},
): 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);
};
const grip = document.createElement("div");
grip.className = `${RESIZE_GRIP_CLASS}${options.touchTargets ? " modal-resize-grip--touch-target" : ""}`;
if (options.touchTargets) {
/*
FNXC:TaskModalResize 2026-07-26-10:40:
Keep the painted corner grip mouse-sized while exposing a separately queryable
touch hit target. This lets tablet touch input land outside the legacy visual
corner without enlarging borders, footer chrome, or content hit areas.
*/
grip.dataset.resizeHitTarget = "true";
}
grip.setAttribute("role", "separator");
grip.setAttribute("aria-label", RESIZE_GRIP_LABEL);
grip.setAttribute("aria-orientation", "vertical");
grip.setAttribute("aria-valuemin", "0");
grip.setAttribute("aria-valuemax", String(window.innerWidth));
grip.tabIndex = 0;
grip.dataset.resizeDirection = "se";
existingGrip?.remove();
node.appendChild(grip);
const syncGripAria = () => {
const { width, height } = readPersistableSize(node);
if (typeof width !== "number" || typeof height !== "number") return;
grip.setAttribute("aria-valuenow", String(Math.round(width)));
grip.setAttribute("aria-valuetext", `Width ${Math.round(width)} pixels, height ${Math.round(height)} pixels`);
};
syncGripAria();
let lastSavedW = node.offsetWidth;
let lastSavedH = node.offsetHeight;
const observer =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(() => {
const w = node.offsetWidth;
const h = node.offsetHeight;
syncGripAria();
if (w === lastSavedW && h === lastSavedH) return;
lastSavedW = w;
lastSavedH = h;
scheduleSave();
});
observer?.observe(node);
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) => {
if (moveEvent.pointerId !== event.pointerId) return;
moveEvent.preventDefault();
const nextWidth = Math.min(window.innerWidth, startWidth + moveEvent.clientX - startX);
const nextHeight = Math.min(window.innerHeight, startHeight + moveEvent.clientY - startY);
if (nextWidth > 0) node.style.width = `${nextWidth}px`;
if (nextHeight > 0) node.style.height = `${nextHeight}px`;
syncGripAria();
scheduleSave();
};
const endDrag = (upEvent: PointerEvent) => {
if (upEvent.pointerId !== event.pointerId) return;
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);
};
const onKeyDown = (event: KeyboardEvent) => {
let widthDelta = 0;
let heightDelta = 0;
if (event.key === "ArrowRight") widthDelta = KEYBOARD_RESIZE_STEP;
if (event.key === "ArrowLeft") widthDelta = -KEYBOARD_RESIZE_STEP;
if (event.key === "ArrowDown") heightDelta = KEYBOARD_RESIZE_STEP;
if (event.key === "ArrowUp") heightDelta = -KEYBOARD_RESIZE_STEP;
if (widthDelta === 0 && heightDelta === 0) return;
event.preventDefault();
event.stopPropagation();
const { width = 0, height = 0 } = readPersistableSize(node);
if (width + widthDelta > 0) node.style.width = `${width + widthDelta}px`;
if (height + heightDelta > 0) node.style.height = `${height + heightDelta}px`;
syncGripAria();
scheduleSave();
};
grip.addEventListener("pointerdown", onPointerDown);
grip.addEventListener("keydown", onKeyDown);
return () => {
cleanupActiveDrag?.();
grip.removeEventListener("pointerdown", onPointerDown);
grip.removeEventListener("keydown", onKeyDown);
grip.remove();
observer?.disconnect();
if (saveTimer) clearTimeout(saveTimer);
};
}, [ref, isOpen, storageKey, options.touchTargets]);
}