Files
SRCmail/components/ui/context-menu.tsx
T
Linus Rath cf02f587de feat: resizable columns, nav rail overhaul, multi-select & drag-drop, UI polish
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)
2026-03-09 16:46:25 +01:00

203 lines
5.1 KiB
TypeScript

"use client";
import { forwardRef, useState, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import { ChevronRight } from "lucide-react";
interface Position {
x: number;
y: number;
}
interface ContextMenuProps {
isOpen: boolean;
position: Position;
onClose: () => void;
children: React.ReactNode;
}
export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
({ isOpen, position, onClose: _onClose, children }, ref) => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted || !isOpen) return null;
return createPortal(
<div
ref={ref}
className={cn(
"fixed z-50 min-w-[200px] bg-background rounded-md shadow-lg border border-border",
"animate-in fade-in-0 zoom-in-95 duration-100"
)}
style={{
left: position.x,
top: position.y,
}}
role="menu"
aria-orientation="vertical"
>
<div className="py-1">
{children}
</div>
</div>,
document.body
);
}
);
ContextMenu.displayName = "ContextMenu";
interface ContextMenuItemProps {
icon?: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
disabled?: boolean;
destructive?: boolean;
shortcut?: string;
}
export function ContextMenuItem({
icon: Icon,
label,
onClick,
disabled = false,
destructive = false,
shortcut,
}: ContextMenuItemProps) {
return (
<button
role="menuitem"
disabled={disabled}
className={cn(
"w-full px-3 py-1.5 text-sm text-left flex items-center gap-2",
"transition-colors duration-150",
"focus:outline-none focus:bg-muted",
disabled && "opacity-50 cursor-not-allowed",
!disabled && "hover:bg-muted cursor-pointer",
destructive && !disabled && "text-destructive hover:bg-destructive/10 focus:bg-destructive/10"
)}
onClick={(e) => {
if (disabled) return;
e.stopPropagation();
onClick();
}}
>
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}
<span className="flex-1">{label}</span>
{shortcut && (
<span className="text-xs text-muted-foreground ml-auto">{shortcut}</span>
)}
</button>
);
}
export function ContextMenuSeparator() {
return <div className="h-px bg-border my-1" role="separator" />;
}
interface ContextMenuSubMenuProps {
icon?: React.ComponentType<{ className?: string }>;
label: string;
children: React.ReactNode;
}
export function ContextMenuSubMenu({
icon: Icon,
label,
children,
}: ContextMenuSubMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const [subMenuPosition, setSubMenuPosition] = useState<"right" | "left">("right");
const itemRef = useRef<HTMLDivElement>(null);
const subMenuRef = useRef<HTMLDivElement>(null);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (isOpen && itemRef.current) {
const rect = itemRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
if (rect.right + 200 > viewportWidth - 10) {
setSubMenuPosition("left");
} else {
setSubMenuPosition("right");
}
}
}, [isOpen]);
useEffect(() => {
return () => clearTimeout(closeTimerRef.current ?? undefined);
}, []);
const handleMouseEnter = () => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
setIsOpen(true);
};
const handleMouseLeave = () => {
closeTimerRef.current = setTimeout(() => {
setIsOpen(false);
}, 150);
};
return (
<div
ref={itemRef}
className="relative"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div
className={cn(
"w-full px-3 py-1.5 text-sm flex items-center gap-2",
"transition-colors duration-150 cursor-pointer",
"hover:bg-muted"
)}
role="menuitem"
aria-haspopup="true"
aria-expanded={isOpen}
>
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}
<span className="flex-1">{label}</span>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</div>
{isOpen && (
<div
ref={subMenuRef}
className={cn(
"absolute top-0 min-w-[180px] bg-background rounded-md shadow-lg border border-border",
"animate-in fade-in-0 zoom-in-95 duration-100",
subMenuPosition === "right" ? "left-full" : "right-full"
)}
role="menu"
>
<div className="py-1 max-h-[300px] overflow-y-auto">
{children}
</div>
</div>
)}
</div>
);
}
interface ContextMenuHeaderProps {
children: React.ReactNode;
}
export function ContextMenuHeader({ children }: ContextMenuHeaderProps) {
return (
<div className="px-3 py-2 text-xs font-medium text-muted-foreground border-b border-border">
{children}
</div>
);
}