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`:
|
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)`.
|
- **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.
|
- **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}`.
|
- **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`.
|
- **Utility**: `.touch-target` (44px min), `.visually-hidden`.
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ describe("task detail modal tablet width (FN-5599)", () => {
|
|||||||
const tabletBlock = tabletBlockMatch![1];
|
const tabletBlock = tabletBlockMatch![1];
|
||||||
const modalRuleMatch = tabletBlock.match(/\.modal\.task-detail-modal\s*\{[^}]*\}/s);
|
const modalRuleMatch = tabletBlock.match(/\.modal\.task-detail-modal\s*\{[^}]*\}/s);
|
||||||
expect(modalRuleMatch).toBeTruthy();
|
expect(modalRuleMatch).toBeTruthy();
|
||||||
expect(modalRuleMatch![0]).toContain("width: min(92vw, 960px);");
|
expect(modalRuleMatch![0]).toContain("width: min(96vw, 1024px);");
|
||||||
expect(modalRuleMatch![0]).toContain("max-width: 92vw;");
|
expect(modalRuleMatch![0]).toContain("max-width: 96vw;");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps mobile full-screen sheet width behavior", () => {
|
it("keeps mobile full-screen sheet width behavior", () => {
|
||||||
|
|||||||
@@ -939,8 +939,8 @@
|
|||||||
/* FN-5599: widen task detail modal on tablet viewports. */
|
/* FN-5599: widen task detail modal on tablet viewports. */
|
||||||
@media (min-width: 769px) and (max-width: 1024px) {
|
@media (min-width: 769px) and (max-width: 1024px) {
|
||||||
.modal.task-detail-modal {
|
.modal.task-detail-modal {
|
||||||
width: min(92vw, 960px);
|
width: min(96vw, 1024px);
|
||||||
max-width: 92vw;
|
max-width: 96vw;
|
||||||
height: 92vh;
|
height: 92vh;
|
||||||
max-height: calc(100dvh - var(--overlay-padding-top, 6vh) - 16px);
|
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 { useEffect, type RefObject } from "react";
|
||||||
|
|
||||||
|
import { isMobileViewport } from "./useViewportMode";
|
||||||
|
|
||||||
interface PersistedSize {
|
interface PersistedSize {
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: 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.
|
* 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
|
// would override the mobile CSS and leave the modal stuck at a partial
|
||||||
// height. Skip restoration; also clear any width/height left over from
|
// height. Skip restoration; also clear any width/height left over from
|
||||||
// a prior desktop render of the same modal instance.
|
// a prior desktop render of the same modal instance.
|
||||||
const isMobile =
|
const existingGrip = node.querySelector(`:scope > .${RESIZE_GRIP_CLASS}`);
|
||||||
typeof window !== "undefined" &&
|
|
||||||
("ontouchstart" in window || navigator.maxTouchPoints > 0) &&
|
if (isMobileViewport()) {
|
||||||
window.innerWidth <= 768;
|
|
||||||
if (isMobile) {
|
|
||||||
node.style.removeProperty("width");
|
node.style.removeProperty("width");
|
||||||
node.style.removeProperty("height");
|
node.style.removeProperty("height");
|
||||||
|
existingGrip?.remove();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,34 +80,109 @@ export function useModalResizePersist(
|
|||||||
// ignore corrupted entry
|
// 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;
|
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const scheduleSave = () => {
|
||||||
const observer = new ResizeObserver(() => {
|
const { width, height } = readPersistableSize(node);
|
||||||
const w = node.offsetWidth;
|
if (typeof width !== "number" || typeof height !== "number") return;
|
||||||
const h = node.offsetHeight;
|
|
||||||
if (w === lastSavedW && h === lastSavedH) return;
|
|
||||||
lastSavedW = w;
|
|
||||||
lastSavedH = h;
|
|
||||||
// Debounce so we don't spam localStorage during the drag.
|
// Debounce so we don't spam localStorage during the drag.
|
||||||
if (saveTimer) clearTimeout(saveTimer);
|
if (saveTimer) clearTimeout(saveTimer);
|
||||||
saveTimer = setTimeout(() => {
|
saveTimer = setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(storageKey, JSON.stringify({ width: w, height: h }));
|
localStorage.setItem(storageKey, JSON.stringify({ width, height }));
|
||||||
} catch {
|
} catch {
|
||||||
// quota / private mode — best-effort
|
// quota / private mode — best-effort
|
||||||
}
|
}
|
||||||
}, 200);
|
}, 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 () => {
|
return () => {
|
||||||
observer.disconnect();
|
cleanupActiveDrag?.();
|
||||||
|
grip.removeEventListener("pointerdown", onPointerDown);
|
||||||
|
grip.remove();
|
||||||
|
observer?.disconnect();
|
||||||
if (saveTimer) clearTimeout(saveTimer);
|
if (saveTimer) clearTimeout(saveTimer);
|
||||||
};
|
};
|
||||||
}, [ref, isOpen, storageKey]);
|
}, [ref, isOpen, storageKey]);
|
||||||
|
|||||||
@@ -1143,6 +1143,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
|
position: relative;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
@@ -1152,6 +1153,37 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
.modal-lg {
|
||||||
width: 640px;
|
width: 640px;
|
||||||
}
|
}
|
||||||
@@ -3433,6 +3465,10 @@ input[type="range"]:focus-visible {
|
|||||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
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 modal: use the section picker as the only mobile navigation */
|
||||||
.settings-layout {
|
.settings-layout {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Reference in New Issue
Block a user