FN-8565: restore tablet task modal resizing
Restore resizable Task Detail and New Task windows on 768px touch tablets. - Classify known touch tablets separately from phone sheets - Restore bounded modal geometry and resize grips with keyboard support - Add regression coverage, documentation, and a patch changeset Files changed: .changeset/fn-8565-tablet-task-modal-resize.md | 7 +++ docs/dashboard-guide.md | 5 ++ .../task-detail-modal-tablet-width.test.ts | 15 +++++ packages/dashboard/app/components/NewTaskModal.css | 18 +++++- packages/dashboard/app/components/NewTaskModal.tsx | 44 +++++++++++++- .../dashboard/app/components/TaskDetailModal.css | 30 ++++++++++ .../dashboard/app/components/TaskDetailModal.tsx | 40 +++++-------- .../app/components/__tests__/NewTaskModal.test.tsx | 68 +++++++++++++++++++++- ...etailModal.responsive-and-dependencies.test.tsx | 51 ++++++++++++++++ .../hooks/__tests__/useModalResizePersist.test.tsx | 53 ++++++++++++++++- .../dashboard/app/hooks/useModalResizePersist.ts | 58 +++++++++++++++--- 11 files changed, 352 insertions(+), 37 deletions(-) Fusion-Task-Id: FN-8565 Fusion-Task-Lineage: 7d9bbeb9-0e6a-4ad9-8b4d-e9f3d8aef650 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8565-tablet-task-modal-resize.md
Normal file
7
.changeset/fn-8565-tablet-task-modal-resize.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Restore touch resizing for Task Detail and New Task on tablets.
|
||||
category: fix
|
||||
dev: Known touch tablets at the 768px boundary now retain floating modal geometry and resize controls.
|
||||
@@ -82,6 +82,11 @@ Press `Escape` to close the current/topmost dashboard popup. Popped-out task win
|
||||
<!-- FNXC:ModalGeometryPersistenceDocs 2026-07-16-00:40: Full-screen mobile FloatingWindow sheets must preserve, rather than overwrite, the movable desktop geometry record so a later desktop reopen restores the user's chosen location and size. -->
|
||||
Movable dashboard pop-outs remember their last desktop location and size, while centered resizable dialogs remember their size. When a pop-out becomes a full-screen sheet at mobile widths (or, for Artifact Gallery, its short-height sheet breakpoint), it leaves that desktop record untouched; reopening it on desktop restores the prior floating geometry.
|
||||
|
||||
<!-- FNXC:TaskModalResizeDocs 2026-08-07-00:00: Known touch tablets at the 768px CSS boundary use the shared physical-screen-aware viewport classification, so documentation must distinguish their resize contract from true phones that share the CSS media query. -->
|
||||
### Task modal resizing on tablets
|
||||
|
||||
Task Detail and New Task remain resizable on known touch tablets, including a 768px-wide tablet viewport. Task Detail exposes its accessible bottom-right resize grip; New Task keeps its draggable header and edge/corner resize controls. Their geometry stays within the viewport and is restored from browser storage on later tablet or desktop opens. True phones and narrow folded panes remain full-screen sheets without active resize controls so keyboard and safe-area behavior is unchanged.
|
||||
|
||||
## Mobile/PWA app icons
|
||||
|
||||
The installed mobile/PWA home-screen icons are generated from `packages/dashboard/app/public/logo.svg` by the desktop icon generator. When the Fusion brand mark changes, run `pnpm --filter @fusion/desktop generate:icons` so `packages/dashboard/app/public/icons/icon-192.png` and `packages/dashboard/app/public/icons/icon-512.png` stay aligned with the canonical logo. Also bump `CACHE_NAME` in `packages/dashboard/app/public/sw.js` whenever those icon assets change so installed PWAs refresh the cached launcher images.
|
||||
|
||||
@@ -33,6 +33,21 @@ describe("task detail modal tablet width (FN-5599, FN-6500)", () => {
|
||||
expect(modalRuleMatch![0]).toContain("max-width: 98vw;");
|
||||
});
|
||||
|
||||
it("overrides phone-sheet geometry and restores the resize grip for a known 768px tablet", () => {
|
||||
const mobileBlockMatch = detailModalCss.match(/@media\s*\(max-width:\s*768px\)\s*\{[\s\S]*?\.task-modal--tablet \.modal-resize-grip\s*\{[^}]*\}[\s\S]*?\n\}/);
|
||||
expect(mobileBlockMatch).toBeTruthy();
|
||||
|
||||
const mobileBlock = mobileBlockMatch![0];
|
||||
const tabletModalRule = mobileBlock.match(/\.modal\.task-detail-modal\.task-modal--tablet\s*\{[^}]*\}/s)?.[0] ?? "";
|
||||
const tabletOverlayRule = mobileBlock.match(/\.modal-overlay:has\(\.task-detail-modal\.task-modal--tablet\)\s*\{[^}]*\}/s)?.[0] ?? "";
|
||||
const tabletGripRule = mobileBlock.match(/\.task-modal--tablet \.modal-resize-grip\s*\{[^}]*\}/s)?.[0] ?? "";
|
||||
|
||||
expect(tabletModalRule).toContain("width: 98vw;");
|
||||
expect(tabletModalRule).toContain("resize: both;");
|
||||
expect(tabletOverlayRule).toContain("--overlay-padding-top: 6vh;");
|
||||
expect(tabletGripRule).toContain("display: block;");
|
||||
});
|
||||
|
||||
it("keeps mobile full-screen sheet width behavior", () => {
|
||||
const mobileBlockMatch = detailModalCss.match(
|
||||
/@media\s*\(max-width:\s*768px\)\s*\{\s*\.detail-move-btn__arrow[\s\S]*?\.modal\.task-detail-modal\s*\{[^}]*\}[\s\S]*?\n\}/,
|
||||
|
||||
@@ -33,7 +33,13 @@ Only the desktop FLOATING New Task dialog joins the shared cross-type floating s
|
||||
FNXC:NewTask 2026-06-22-20:30:
|
||||
Floating panel positioned by state-driven inline left/top/width/height. min/max keep content usable and the panel on-screen; `resize: none` because the corner/edge handles own resizing (the native grip conflicts with the pointer handlers). `pointer-events: auto` re-enables interaction on the panel only. Desktop only — mobile keeps the full-screen keyboard-aware sheet.
|
||||
*/
|
||||
.new-task-modal--floating {
|
||||
/*
|
||||
FNXC:TaskModalResize 2026-08-07-00:00:
|
||||
The floating class is also applied to known tablets at the 768px CSS boundary.
|
||||
Keep this selector more specific than the global phone-sheet rule; the runtime
|
||||
viewport contract, not a duplicate CSS breakpoint, decides whether it applies.
|
||||
*/
|
||||
.modal.new-task-modal.new-task-modal--floating {
|
||||
--floating-window-shadow: var(--shadow-lg);
|
||||
position: fixed;
|
||||
display: flex;
|
||||
@@ -74,12 +80,22 @@ Header is the drag handle. `touch-action: none` (matching the resize handles) ha
|
||||
FNXC:NewTask 2026-06-22-20:30:
|
||||
Edge + corner resize handles. touch-action:none keeps the drag from being hijacked by scroll/gestures so resizing stays smooth.
|
||||
*/
|
||||
/*
|
||||
FNXC:TaskModalResize 2026-07-24-19:00:
|
||||
Resize grips are focusable separators on floating desktop and tablet task windows. Preserve a
|
||||
visible tokenized focus ring so keyboard users can discover the same controls touch users drag.
|
||||
*/
|
||||
.new-task-resize-handle {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.new-task-resize-handle:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.new-task-resize-handle--n,
|
||||
.new-task-resize-handle--s {
|
||||
left: var(--space-sm);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "./NewTaskModal.css";
|
||||
import { useState, useCallback, useEffect, useRef, type CSSProperties, type ChangeEvent, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { useState, useCallback, useEffect, useRef, type CSSProperties, type ChangeEvent, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DEFAULT_TASK_PRIORITY, type Task, type TaskPriority } from "@fusion/core";
|
||||
@@ -65,6 +65,11 @@ const NEW_TASK_DEFAULT_HEIGHT = 640;
|
||||
const NEW_TASK_MIN_WIDTH = 420;
|
||||
const NEW_TASK_MIN_HEIGHT = 360;
|
||||
const NEW_TASK_VIEWPORT_PADDING = 16;
|
||||
/*
|
||||
FNXC:TaskModalResize 2026-07-24-19:00:
|
||||
Keyboard resizing follows the existing viewport padding quantum, so focusable edge controls produce predictable, touch-safe geometry changes without a second sizing scale.
|
||||
*/
|
||||
const NEW_TASK_KEYBOARD_RESIZE_STEP = NEW_TASK_VIEWPORT_PADDING;
|
||||
|
||||
interface FloatSize {
|
||||
width: number;
|
||||
@@ -562,6 +567,34 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
captureTarget.addEventListener("pointercancel", handlePointerUp);
|
||||
}, [persistPosition, persistSize, position, size]);
|
||||
|
||||
/*
|
||||
FNXC:TaskModalResize 2026-07-24-19:00:
|
||||
Tablet resize handles must be keyboard-operable as well as touch-operable. Each focused edge
|
||||
adjusts the dimensions it owns, clamps and persists exactly like a completed pointer resize,
|
||||
and exposes the resulting geometry through its ARIA separator value.
|
||||
*/
|
||||
const handleFloatingResizeKeyDown = useCallback((event: ReactKeyboardEvent<HTMLDivElement>, direction: FloatResizeDirection) => {
|
||||
let widthDelta = 0;
|
||||
let heightDelta = 0;
|
||||
const step = NEW_TASK_KEYBOARD_RESIZE_STEP;
|
||||
|
||||
if (event.key === "ArrowRight") widthDelta = direction.includes("e") ? step : direction.includes("w") ? -step : 0;
|
||||
if (event.key === "ArrowLeft") widthDelta = direction.includes("w") ? step : direction.includes("e") ? -step : 0;
|
||||
if (event.key === "ArrowDown") heightDelta = direction.includes("s") ? step : direction.includes("n") ? -step : 0;
|
||||
if (event.key === "ArrowUp") heightDelta = direction.includes("n") ? step : direction.includes("s") ? -step : 0;
|
||||
if (widthDelta === 0 && heightDelta === 0) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const nextSize = clampFloatSize({ width: size.width + widthDelta, height: size.height + heightDelta });
|
||||
const nextPosition = clampFloatPosition({
|
||||
x: position.x + (direction.includes("w") ? size.width - nextSize.width : 0),
|
||||
y: position.y + (direction.includes("n") ? size.height - nextSize.height : 0),
|
||||
}, nextSize);
|
||||
persistSize(nextSize);
|
||||
persistPosition(nextPosition, nextSize);
|
||||
}, [persistPosition, persistSize, position, size]);
|
||||
|
||||
// FNXC:NewTask 2026-06-22-20:30: Run any active drag/resize teardown on unmount so element pointer listeners + a pending rAF never outlive the modal.
|
||||
useEffect(() => () => dragTeardownRef.current?.(), []);
|
||||
|
||||
@@ -1173,7 +1206,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
style={isFloating ? { zIndex } : undefined}
|
||||
>
|
||||
<div
|
||||
className={`modal modal-lg new-task-modal${isFloating ? " new-task-modal--floating" : ""}`}
|
||||
className={`modal modal-lg new-task-modal${viewportMode === "tablet" ? " task-modal--tablet" : ""}${isFloating ? " new-task-modal--floating" : ""}`}
|
||||
style={panelStyle}
|
||||
onPointerDownCapture={isFloating ? bringToFront : undefined}
|
||||
onFocusCapture={isFloating ? bringToFront : undefined}
|
||||
@@ -1184,8 +1217,15 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
className={`new-task-resize-handle new-task-resize-handle--${direction}`}
|
||||
data-testid={`new-task-resize-${direction}`}
|
||||
role="separator"
|
||||
aria-orientation={direction === "n" || direction === "s" ? "horizontal" : "vertical"}
|
||||
aria-valuemin={direction === "n" || direction === "s" ? NEW_TASK_MIN_HEIGHT : NEW_TASK_MIN_WIDTH}
|
||||
aria-valuemax={direction === "n" || direction === "s" ? Math.max(NEW_TASK_MIN_HEIGHT, window.innerHeight - NEW_TASK_VIEWPORT_PADDING * 2) : Math.max(NEW_TASK_MIN_WIDTH, window.innerWidth - NEW_TASK_VIEWPORT_PADDING * 2)}
|
||||
aria-valuenow={direction === "n" || direction === "s" ? size.height : size.width}
|
||||
aria-valuetext={`${t("newTaskModal.resize", "Resize new task window")}: ${size.width} by ${size.height}`}
|
||||
aria-label={t("newTaskModal.resize", "Resize new task window")}
|
||||
tabIndex={0}
|
||||
onPointerDown={(event) => handleFloatingResizePointerDown(event, direction)}
|
||||
onKeyDown={(event) => handleFloatingResizeKeyDown(event, direction)}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
|
||||
@@ -1786,6 +1786,36 @@ FN-6500 fixes a tablet regression from FN-5599: the task-detail overlay offset a
|
||||
resize: none;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TaskModalResize 2026-08-07-00:00:
|
||||
Phone CSS also matches a known 768px touch tablet. The shared runtime class
|
||||
records the physical-screen-aware tablet result, restoring bounded modal
|
||||
geometry and the existing resize grip without weakening true-phone sheets.
|
||||
*/
|
||||
.modal-overlay:has(.task-detail-modal.task-modal--tablet) {
|
||||
--overlay-padding-top: 6vh;
|
||||
padding-top: var(--overlay-padding-top);
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal.task-detail-modal.task-modal--tablet {
|
||||
width: 98vw;
|
||||
min-width: 480px;
|
||||
max-width: 98vw;
|
||||
height: 92vh;
|
||||
min-height: 480px;
|
||||
max-height: calc(100dvh - var(--overlay-padding-top, 6vh) - var(--space-md));
|
||||
margin: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
resize: both;
|
||||
}
|
||||
|
||||
.task-modal--tablet .modal-resize-grip {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.detail-body--chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap, Loader2, AlertTriangle, Sparkles, Maximize2, Minimize2, Send, Square, Info, Paperclip, Eye, EyeOff } from "lucide-react";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
import { useViewportMode } from "../hooks/useViewportMode";
|
||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
import { useColumnLabel } from "../i18n/labels";
|
||||
@@ -92,9 +93,8 @@ const ACTIVITY_VIEW_MENU_MIN_WIDTH = 160;
|
||||
const ACTIVITY_VIEW_MENU_MIN_HEIGHT = 120;
|
||||
const ACTIVITY_VIEW_MENU_MAX_HEIGHT = 320;
|
||||
const ACTIVITY_VIEW_MENU_OPEN_VIEWPORT_GUARD_MS = 350;
|
||||
// FNXC:TaskDetailSwipeBack 2026-07-05-12:30: FN-7587 — mobile breakpoint gating the presentation-only predictive-back slide/fade transition on the modal/list/nested task-detail surface; matches the `@media (max-width: 768px)` convention already used in this file.
|
||||
// FNXC:TaskDetailSwipeBack 2026-07-05-12:30: FN-7587 — mobile-mode gating the presentation-only predictive-back slide/fade transition on the modal/list/nested task-detail surface uses the shared viewport classifier, so known 768px tablets do not receive phone-only presentation.
|
||||
// FNXC:PlannerOversight 2026-07-05-00:00: FN-7604 — the OVERSIGHT_MENU_MOBILE_BREAKPOINT constant (formerly used to branch the oversight controls between an inline cluster and this overflow menu) was removed; the overflow-menu dropdown is now the single universal surface at every viewport, so no breakpoint gates it.
|
||||
const TASK_DETAIL_MOBILE_TRANSITION_BREAKPOINT = 768;
|
||||
|
||||
type ActivityViewMenuPosition = {
|
||||
top: number;
|
||||
@@ -6523,33 +6523,25 @@ export function TaskDetailContent({
|
||||
|
||||
export function TaskDetailModal({ onClose, ...props }: TaskDetailModalProps) {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const viewportMode = useViewportMode();
|
||||
useModalResizePersist(modalRef, true, "task-detail-modal-size");
|
||||
useMobileScrollLock(true);
|
||||
const overlayDismissProps = useOverlayDismiss(onClose);
|
||||
/*
|
||||
FNXC:TaskDetailSwipeBack 2026-07-05-12:30:
|
||||
FN-7587 — track the mobile breakpoint locally (mirrors the same resize-listener pattern
|
||||
used elsewhere in this file) so the list/modal/nested task-detail surface gets the same
|
||||
presentation-only predictive-back slide/fade enter transition as the board main-panel
|
||||
(MainContent.tsx), without threading a new isMobile prop through App.tsx/AppModals.tsx. This
|
||||
is presentation-only: it never touches onClose/onRequestClose timing or the underlying
|
||||
useNavigationHistory dismissal routing, and honors prefers-reduced-motion (see
|
||||
TaskDetailModal.css). Defaults false so JSDOM/unit tests keep exercising the desktop (no
|
||||
animation) branch unless a test explicitly narrows the viewport.
|
||||
FNXC:TaskDetailSwipeBack 2026-08-07-00:00:
|
||||
Gate predictive-back animation through useViewportMode, the same physical-screen-aware
|
||||
classifier used for resize behavior. This preserves phone animation while keeping known
|
||||
768px tablets in their desktop/tablet presentation.
|
||||
*/
|
||||
const [isMobileTransition, setIsMobileTransition] = useState(false);
|
||||
useEffect(() => {
|
||||
const updateIsMobileTransition = () => {
|
||||
setIsMobileTransition(window.innerWidth <= TASK_DETAIL_MOBILE_TRANSITION_BREAKPOINT);
|
||||
};
|
||||
const isMobileTransition = viewportMode === "mobile";
|
||||
|
||||
updateIsMobileTransition();
|
||||
window.addEventListener("resize", updateIsMobileTransition);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateIsMobileTransition);
|
||||
};
|
||||
}, []);
|
||||
/*
|
||||
FNXC:TaskModalResize 2026-08-07-00:00:
|
||||
Known touch tablets at the 768px CSS boundary resolve to `tablet` through
|
||||
useViewportMode. Carry that single classification into the modal class so CSS
|
||||
can override phone-sheet rules without a second breakpoint or gesture system.
|
||||
*/
|
||||
const isTabletTaskModal = viewportMode === "tablet";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -6559,7 +6551,7 @@ export function TaskDetailModal({ onClose, ...props }: TaskDetailModalProps) {
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
className={`modal modal-lg task-detail-modal${isMobileTransition ? " task-detail-modal--mobile-transition" : ""}`}
|
||||
className={`modal modal-lg task-detail-modal${isTabletTaskModal ? " task-modal--tablet" : ""}${isMobileTransition ? " task-detail-modal--mobile-transition" : ""}`}
|
||||
ref={modalRef}
|
||||
>
|
||||
<TaskDetailContent
|
||||
|
||||
@@ -80,7 +80,7 @@ vi.mock("../../hooks/useMobileKeyboard", () => ({
|
||||
}));
|
||||
|
||||
// FNXC:NewTask 2026-06-22-20:30: viewport mode is switchable so we can exercise both the mobile sheet (default) and the desktop floating window. Defaults to mobile to preserve the existing suite's layout assumptions.
|
||||
let mockViewportMode: "mobile" | "desktop" = "mobile";
|
||||
let mockViewportMode: "mobile" | "tablet" | "desktop" = "mobile";
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
|
||||
isFullScreenSheetViewport: () => false,
|
||||
@@ -2181,6 +2181,20 @@ describe("NewTaskModal", () => {
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps tablet-class New Task floating and exposes touch resize controls", () => {
|
||||
mockViewportMode = "tablet";
|
||||
renderNewTaskModal();
|
||||
|
||||
expect(screen.getByTestId("new-task-drag-handle")).toHaveClass("new-task-modal__header--draggable");
|
||||
expect(document.querySelector(".new-task-modal")).toHaveClass("task-modal--tablet");
|
||||
expect(document.querySelector(".new-task-modal")).toHaveClass("new-task-modal--floating");
|
||||
for (const dir of ["n", "s", "e", "w", "ne", "nw", "se", "sw"]) {
|
||||
expect(screen.getByTestId(`new-task-resize-${dir}`)).toHaveAttribute("role", "separator");
|
||||
expect(screen.getByTestId(`new-task-resize-${dir}`)).toHaveAttribute("aria-label", "Resize new task window");
|
||||
expect(screen.getByTestId(`new-task-resize-${dir}`)).toHaveAttribute("tabindex", "0");
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes a draggable header handle and resize handles", () => {
|
||||
renderNewTaskModal();
|
||||
|
||||
@@ -2194,6 +2208,58 @@ describe("NewTaskModal", () => {
|
||||
expect(panel).not.toBeNull();
|
||||
});
|
||||
|
||||
it("resizes from a focused tablet handle with keyboard controls and exposes updated geometry", () => {
|
||||
mockViewportMode = "tablet";
|
||||
renderNewTaskModal();
|
||||
|
||||
const panel = document.querySelector(".new-task-modal--floating") as HTMLElement;
|
||||
const handle = screen.getByTestId("new-task-resize-se");
|
||||
const initialWidth = Number.parseFloat(panel.style.width);
|
||||
const initialHeight = Number.parseFloat(panel.style.height);
|
||||
handle.focus();
|
||||
|
||||
expect(handle).toHaveFocus();
|
||||
expect(handle).toHaveAttribute("aria-valuenow", String(initialWidth));
|
||||
expect(handle).toHaveAttribute("aria-valuetext", `Resize new task window: ${initialWidth} by ${initialHeight}`);
|
||||
|
||||
fireEvent.keyDown(handle, { key: "ArrowRight" });
|
||||
fireEvent.keyDown(handle, { key: "ArrowDown" });
|
||||
|
||||
expect(Number.parseFloat(panel.style.width)).toBeGreaterThan(initialWidth);
|
||||
expect(Number.parseFloat(panel.style.height)).toBeGreaterThan(initialHeight);
|
||||
expect(handle).toHaveAttribute("aria-valuenow", panel.style.width.replace("px", ""));
|
||||
expect(handle).toHaveAttribute("aria-valuetext", `Resize new task window: ${panel.style.width.replace("px", "")} by ${panel.style.height.replace("px", "")}`);
|
||||
expect(JSON.parse(window.localStorage.getItem("fusion:new-task-modal-size") ?? "{}")).toMatchObject({
|
||||
width: Number.parseFloat(panel.style.width),
|
||||
height: Number.parseFloat(panel.style.height),
|
||||
});
|
||||
});
|
||||
|
||||
it("resizes from a tablet touch handle without dismissing or leaking selection state", () => {
|
||||
mockViewportMode = "tablet";
|
||||
const onClose = vi.fn();
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
||||
renderNewTaskModal({ onClose });
|
||||
|
||||
const panel = document.querySelector(".new-task-modal--floating") as HTMLElement;
|
||||
const handle = screen.getByTestId("new-task-resize-se");
|
||||
const initialWidth = Number.parseFloat(panel.style.width);
|
||||
const initialHeight = Number.parseFloat(panel.style.height);
|
||||
|
||||
fireEvent.pointerDown(handle, { pointerId: 4, clientX: 100, clientY: 100, pointerType: "touch" });
|
||||
fireEvent.pointerMove(handle, { pointerId: 4, clientX: 140, clientY: 130, pointerType: "touch" });
|
||||
fireEvent.pointerUp(handle, { pointerId: 4, clientX: 140, clientY: 130, pointerType: "touch" });
|
||||
|
||||
expect(Number.parseFloat(panel.style.width)).toBeGreaterThan(initialWidth);
|
||||
expect(Number.parseFloat(panel.style.height)).toBeGreaterThan(initialHeight);
|
||||
expect(document.body.style.userSelect).toBe("");
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the floating window touch-draggable with theme-controlled shadow", () => {
|
||||
const panelRule = newTaskModalCss.match(/\.new-task-modal--floating\s*\{([^}]*)\}/)?.[1] ?? "";
|
||||
const headerRule = newTaskModalCss.match(/\.new-task-modal__header--draggable\s*\{([^}]*)\}/)?.[1] ?? "";
|
||||
|
||||
@@ -843,6 +843,57 @@ describe("TaskDetailModal", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps Task Detail resizable for a known touch tablet at 768px", () => {
|
||||
const originalScreen = Object.getOwnPropertyDescriptor(window, "screen");
|
||||
const originalMaxTouchPoints = Object.getOwnPropertyDescriptor(navigator, "maxTouchPoints");
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
Object.defineProperty(window, "screen", { configurable: true, value: { width: 768, height: 1024 } });
|
||||
Object.defineProperty(navigator, "maxTouchPoints", { configurable: true, value: 1 });
|
||||
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
|
||||
matches: query === "(max-width: 768px)" || query === "(max-width: 768px), (max-height: 480px)",
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
})));
|
||||
|
||||
try {
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ column: "in-progress" as Column })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
const modal = container.querySelector(".task-detail-modal");
|
||||
expect(modal).toHaveClass("task-modal--tablet");
|
||||
const grip = modal?.querySelector(".modal-resize-grip") as HTMLElement;
|
||||
expect(grip).toHaveAttribute("aria-label", "Resize modal from bottom-right corner");
|
||||
expect(grip).toHaveAttribute("tabindex", "0");
|
||||
|
||||
// FNXC:TaskModalResize 2026-07-24-19:20: The 768px tablet recovery must
|
||||
// remain keyboard discoverable, not merely restore a touch-only grip.
|
||||
modal!.style.width = "500px";
|
||||
modal!.style.height = "400px";
|
||||
grip.focus();
|
||||
fireEvent.keyDown(grip, { key: "ArrowRight" });
|
||||
fireEvent.keyDown(grip, { key: "ArrowDown" });
|
||||
|
||||
expect(modal!.style.width).toBe("516px");
|
||||
expect(modal!.style.height).toBe("416px");
|
||||
expect(grip).toHaveAttribute("aria-valuenow", "516");
|
||||
expect(grip).toHaveAttribute("aria-valuetext", "Width 516 pixels, height 416 pixels");
|
||||
} finally {
|
||||
if (originalScreen) Object.defineProperty(window, "screen", originalScreen);
|
||||
if (originalMaxTouchPoints) Object.defineProperty(navigator, "maxTouchPoints", originalMaxTouchPoints);
|
||||
vi.stubGlobal("matchMedia", originalMatchMedia);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders responsive structural classes (modal-lg, overlay, spacer, tabs, detail-body)", () => {
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useRef } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -159,6 +159,57 @@ describe("useModalResizePersist", () => {
|
||||
.toEqual({ width: 560, height: 445 });
|
||||
});
|
||||
|
||||
it("resizes from a focused grip with arrow keys and exposes synchronized geometry", () => {
|
||||
setViewport(900);
|
||||
render(<Harness />);
|
||||
|
||||
const modal = screen.getByTestId("modal");
|
||||
installModalGeometry(modal);
|
||||
triggerResizeObservers();
|
||||
const grip = modal.querySelector(".modal-resize-grip") as HTMLElement;
|
||||
grip.focus();
|
||||
|
||||
expect(grip).toHaveFocus();
|
||||
expect(grip).toHaveAttribute("tabindex", "0");
|
||||
expect(grip).toHaveAttribute("aria-valuenow", "500");
|
||||
expect(grip).toHaveAttribute("aria-valuetext", "Width 500 pixels, height 400 pixels");
|
||||
|
||||
fireEvent.keyDown(grip, { key: "ArrowRight" });
|
||||
fireEvent.keyDown(grip, { key: "ArrowDown" });
|
||||
|
||||
expect(modal.style.width).toBe("516px");
|
||||
expect(modal.style.height).toBe("416px");
|
||||
expect(grip).toHaveAttribute("aria-valuenow", "516");
|
||||
expect(grip).toHaveAttribute("aria-valuetext", "Width 516 pixels, height 416 pixels");
|
||||
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}"))
|
||||
.toEqual({ width: 516, height: 416 });
|
||||
});
|
||||
|
||||
it("keeps the grip active at the known touch-tablet 768px boundary", () => {
|
||||
const originalMaxTouchPoints = Object.getOwnPropertyDescriptor(navigator, "maxTouchPoints");
|
||||
setViewport(768, 1024);
|
||||
Object.defineProperty(navigator, "maxTouchPoints", { configurable: true, value: 1 });
|
||||
|
||||
try {
|
||||
render(<Harness />);
|
||||
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: 10, pointerId: 7 });
|
||||
dispatchPointerEvent(document, "pointermove", { clientX: 50, clientY: 30, pointerId: 7 });
|
||||
dispatchPointerEvent(document, "pointerup", { clientX: 50, clientY: 30, pointerId: 7 });
|
||||
|
||||
expect(modal.style.width).toBe("540px");
|
||||
expect(modal.style.height).toBe("420px");
|
||||
} finally {
|
||||
if (originalMaxTouchPoints) Object.defineProperty(navigator, "maxTouchPoints", originalMaxTouchPoints);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps desktop grip and native ResizeObserver persistence/restore behavior", () => {
|
||||
setViewport(1280);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ width: 610, height: 480 }));
|
||||
|
||||
@@ -10,6 +10,14 @@ interface PersistedSize {
|
||||
const RESIZE_GRIP_CLASS = "modal-resize-grip";
|
||||
const RESIZE_GRIP_LABEL = "Resize modal from bottom-right corner";
|
||||
|
||||
/*
|
||||
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);
|
||||
@@ -95,6 +103,26 @@ export function useModalResizePersist(
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const grip = document.createElement("div");
|
||||
grip.className = RESIZE_GRIP_CLASS;
|
||||
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 =
|
||||
@@ -103,6 +131,7 @@ export function useModalResizePersist(
|
||||
: new ResizeObserver(() => {
|
||||
const w = node.offsetWidth;
|
||||
const h = node.offsetHeight;
|
||||
syncGripAria();
|
||||
if (w === lastSavedW && h === lastSavedH) return;
|
||||
lastSavedW = w;
|
||||
lastSavedH = h;
|
||||
@@ -111,14 +140,6 @@ export function useModalResizePersist(
|
||||
|
||||
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) => {
|
||||
@@ -149,6 +170,7 @@ export function useModalResizePersist(
|
||||
const nextHeight = startHeight + moveEvent.clientY - startY;
|
||||
if (nextWidth > 0) node.style.width = `${nextWidth}px`;
|
||||
if (nextHeight > 0) node.style.height = `${nextHeight}px`;
|
||||
syncGripAria();
|
||||
scheduleSave();
|
||||
};
|
||||
|
||||
@@ -176,11 +198,31 @@ export function useModalResizePersist(
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user