Resizable Columns - Add ResizeHandle component with mouse drag, keyboard (Arrow keys), and double-click to reset to default width - Add sidebarWidth/emailListWidth to ui-store with clamping + persistence - Wire resize handles between sidebar/email-list panels on desktop Navigation Rail Overhaul - Move StorageQuotaCircle, push-status, sign-out from Sidebar to NavigationRail - Interactive SVG ring with popover breakdown (used/free/total) - Sidebar collapse state lifted to ui-store - Show total email count per mailbox alongside unread badge Email Multi-Selection & Drag-and-Drop - Ctrl+Click (toggle) and Shift+Click (range) on all list items - Add selectRangeEmails and lastSelectedEmailId to email-store - Enable drag-and-drop on thread items and thread headers - useEmailDrag accepts optional threadEmails for full-thread drag Email Viewer Layout - Remove card wrapper for cleaner full-width reading - Always render HTML body when available - Adjust skeleton loader to match flat layout Modal & UI Polish - Standardise backdrops, close buttons, padding, border-radius, transitions - Migrate template-string classNames to cn() in settings - Unify focus-ring token to ring-ring on form controls i18n - Add storage_used/free/total keys to all 8 locales Dev Mock JMAP Server (new, gated by DEV_MOCK_JMAP=true) - Session, Mailbox/Email/Thread/Identity CRUD, back-references, upload - GET /download with Content-Disposition, GET /eventsource SSE Tests (46 new) - ui-store (13), email-selection (10), resize-handle (9), mock-server (14)
80 lines
2.3 KiB
TypeScript
80 lines
2.3 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef } from "react";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface ResizeHandleProps {
|
|
onResize: (delta: number) => void;
|
|
onResizeEnd?: () => void;
|
|
onDoubleClick?: () => void;
|
|
className?: string;
|
|
}
|
|
|
|
const KEYBOARD_STEP = 10;
|
|
|
|
export function ResizeHandle({ onResize, onResizeEnd, onDoubleClick, className }: ResizeHandleProps) {
|
|
const isDragging = useRef(false);
|
|
const lastX = useRef(0);
|
|
|
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
isDragging.current = true;
|
|
lastX.current = e.clientX;
|
|
document.body.style.cursor = "col-resize";
|
|
document.body.style.userSelect = "none";
|
|
}, []);
|
|
|
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
let delta = 0;
|
|
if (e.key === "ArrowLeft") delta = -KEYBOARD_STEP;
|
|
else if (e.key === "ArrowRight") delta = KEYBOARD_STEP;
|
|
else return;
|
|
e.preventDefault();
|
|
onResize(delta);
|
|
onResizeEnd?.();
|
|
}, [onResize, onResizeEnd]);
|
|
|
|
useEffect(() => {
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
if (!isDragging.current) return;
|
|
const delta = e.clientX - lastX.current;
|
|
lastX.current = e.clientX;
|
|
onResize(delta);
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
if (!isDragging.current) return;
|
|
isDragging.current = false;
|
|
document.body.style.cursor = "";
|
|
document.body.style.userSelect = "";
|
|
onResizeEnd?.();
|
|
};
|
|
|
|
document.addEventListener("mousemove", handleMouseMove);
|
|
document.addEventListener("mouseup", handleMouseUp);
|
|
return () => {
|
|
document.removeEventListener("mousemove", handleMouseMove);
|
|
document.removeEventListener("mouseup", handleMouseUp);
|
|
};
|
|
}, [onResize, onResizeEnd]);
|
|
|
|
return (
|
|
<div
|
|
role="separator"
|
|
aria-orientation="vertical"
|
|
aria-label="Resize"
|
|
tabIndex={0}
|
|
onMouseDown={handleMouseDown}
|
|
onKeyDown={handleKeyDown}
|
|
onDoubleClick={onDoubleClick}
|
|
className={cn(
|
|
"w-1 flex-shrink-0 cursor-col-resize hover:bg-primary/30 active:bg-primary/50 transition-colors relative group",
|
|
"focus-visible:outline-none focus-visible:bg-primary/40 focus-visible:ring-2 focus-visible:ring-primary/50",
|
|
className
|
|
)}
|
|
>
|
|
<div className="absolute inset-y-0 -left-1 -right-1" />
|
|
</div>
|
|
);
|
|
}
|