fix: folder flyout context menu stays open + intra-folder DnD reordering

Context menu no longer closes the flyout when right-clicking a space inside
a folder. Added data-flyout-safe attribute so click-outside detection skips
portaled context menu elements. Added drag-and-drop reordering within folder
flyouts with drop indicators and layout persistence.
This commit is contained in:
Jannis Braun
2026-03-12 14:38:56 +01:00
parent 7b13b36a40
commit 7a7e0784d8
@@ -346,6 +346,8 @@ function FolderFlyout({
onSpaceContextMenu, onSpaceContextMenu,
onRename, onRename,
onDragStart, onDragStart,
onReorder,
onParentDragEnd,
}: { }: {
folder: SpaceFolder; folder: SpaceFolder;
spaces: TaggedSpace[]; spaces: TaggedSpace[];
@@ -359,6 +361,8 @@ function FolderFlyout({
onSpaceContextMenu: (spaceId: string, e: React.MouseEvent) => void; onSpaceContextMenu: (spaceId: string, e: React.MouseEvent) => void;
onRename: (name: string) => void; onRename: (name: string) => void;
onDragStart: (e: React.DragEvent, spaceId: string) => void; onDragStart: (e: React.DragEvent, spaceId: string) => void;
onReorder: (reorderedSpaceIds: string[]) => void;
onParentDragEnd: () => void;
}) { }) {
const anchorRef = useRef<HTMLDivElement>(anchorEl); const anchorRef = useRef<HTMLDivElement>(anchorEl);
anchorRef.current = anchorEl; anchorRef.current = anchorEl;
@@ -369,12 +373,86 @@ function FolderFlyout({
offset: 12, offset: 12,
}); });
// Intra-folder DnD state
const [flyoutDrop, setFlyoutDrop] = useState<{
targetSpaceId: string;
position: 'before' | 'after';
} | null>(null);
const flyoutDropRef = useRef(flyoutDrop);
flyoutDropRef.current = flyoutDrop;
const handleFlyoutDragOver = useCallback((e: React.DragEvent, targetSpaceId: string) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
const rect = e.currentTarget.getBoundingClientRect();
const relY = e.clientY - rect.top;
const position: 'before' | 'after' = relY < rect.height * 0.5 ? 'before' : 'after';
// Normalize 'before' to previous item's 'after' so a single indicator renders
if (position === 'before') {
const idx = spaces.findIndex(s => s.id === targetSpaceId);
if (idx > 0) {
const prevId = spaces[idx - 1]!.id;
setFlyoutDrop({ targetSpaceId: prevId, position: 'after' });
return;
}
}
setFlyoutDrop({ targetSpaceId, position });
}, [spaces]);
const handleFlyoutDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
const indicator = flyoutDropRef.current;
const dragId = e.dataTransfer.getData('text/plain');
if (!indicator || !dragId) {
setFlyoutDrop(null);
onParentDragEnd();
return;
}
// Don't reorder if dropping on self in same position
const currentIds = spaces.map(s => s.id);
const dragIdx = currentIds.indexOf(dragId);
if (dragIdx === -1) {
// Dragged space is not in this folder — let parent handle it
setFlyoutDrop(null);
onParentDragEnd();
return;
}
// Remove dragged space and re-insert at target position
const without = currentIds.filter(id => id !== dragId);
const targetIdx = without.indexOf(indicator.targetSpaceId);
if (targetIdx === -1) {
setFlyoutDrop(null);
onParentDragEnd();
return;
}
const insertIdx = indicator.position === 'before' ? targetIdx : targetIdx + 1;
without.splice(insertIdx, 0, dragId);
onReorder(without);
setFlyoutDrop(null);
onParentDragEnd();
}, [spaces, onReorder, onParentDragEnd]);
const handleFlyoutDragEnd = useCallback(() => {
setFlyoutDrop(null);
onParentDragEnd();
}, [onParentDragEnd]);
// Close on click-outside and Escape // Close on click-outside and Escape
useEffect(() => { useEffect(() => {
const handleClickOutside = (e: MouseEvent) => { const handleClickOutside = (e: MouseEvent) => {
if ( if (
floatingRef.current && !floatingRef.current.contains(e.target as Node) && floatingRef.current && !floatingRef.current.contains(e.target as Node) &&
!anchorEl.contains(e.target as Node) !anchorEl.contains(e.target as Node) &&
!(e.target as HTMLElement).closest?.('[data-flyout-safe]')
) { ) {
onClose(); onClose();
} }
@@ -421,7 +499,7 @@ function FolderFlyout({
)} )}
{/* Space rows */} {/* Space rows */}
{spaces.map((space) => { {spaces.map((space, idx) => {
const isActive = currentSpaceId === space.id; const isActive = currentSpaceId === space.id;
const hasUnread = unreadSpaceIds.has(space.id) && !isActive; const hasUnread = unreadSpaceIds.has(space.id) && !isActive;
const origin = space._instanceOrigin; const origin = space._instanceOrigin;
@@ -431,21 +509,28 @@ function FolderFlyout({
const grad = !icon ? getSpaceGradient(space.id, space.name, space.avatarColor) : null; const grad = !icon ? getSpaceGradient(space.id, space.name, space.avatarColor) : null;
return ( return (
<React.Fragment key={space.id}>
{/* Drop indicator: before first item */}
{idx === 0 && flyoutDrop?.targetSpaceId === space.id && flyoutDrop.position === 'before' && (
<div className="h-0.5 bg-accent-mint rounded-full mx-2.5 my-0.5" />
)}
<button <button
key={space.id}
className={`w-full flex items-center gap-2.5 px-2.5 py-1.5 mx-1 rounded-md transition-colors ${ className={`w-full flex items-center gap-2.5 px-2.5 py-1.5 mx-1 rounded-md transition-colors ${
isDimmed ? 'opacity-40 saturate-50' : '' isDimmed ? 'opacity-40 saturate-50' : ''
} ${isActive ? 'bg-white/[0.10]' : 'hover:bg-white/[0.06]'}`} } ${isActive ? 'bg-white/[0.10]' : 'hover:bg-white/[0.06]'}`}
style={{ width: 'calc(100% - 8px)' }} style={{ width: 'calc(100% - 8px)' }}
draggable draggable
onDragStart={(e) => onDragStart(e, space.id)} onDragStart={(e) => onDragStart(e, space.id)}
onDragOver={(e) => handleFlyoutDragOver(e, space.id)}
onDrop={handleFlyoutDrop}
onDragEnd={handleFlyoutDragEnd}
onClick={() => { onClick={() => {
onSpaceClick(space.id); onSpaceClick(space.id);
onClose(); onClose();
}} }}
onContextMenu={(e) => { onContextMenu={(e) => {
onSpaceContextMenu(space.id, e); onSpaceContextMenu(space.id, e);
onClose();
}} }}
> >
{/* Space icon */} {/* Space icon */}
@@ -476,6 +561,12 @@ function FolderFlyout({
<div className="w-2 h-2 rounded-full bg-white flex-shrink-0" /> <div className="w-2 h-2 rounded-full bg-white flex-shrink-0" />
)} )}
</button> </button>
{/* Drop indicator: after item */}
{flyoutDrop?.targetSpaceId === space.id && flyoutDrop.position === 'after' && (
<div className="h-0.5 bg-accent-mint rounded-full mx-2.5 my-0.5" />
)}
</React.Fragment>
); );
})} })}
</div>, </div>,
@@ -556,6 +647,7 @@ function SpaceContextMenu({ spaceId, x, y, onClose }: { spaceId: string; x: numb
return ReactDOM.createPortal( return ReactDOM.createPortal(
<div <div
ref={menuRef} ref={menuRef}
data-flyout-safe
className="fixed z-[9999] min-w-[160px] glass rounded-lg py-1 animate-in fade-in zoom-in-95 duration-100" className="fixed z-[9999] min-w-[160px] glass rounded-lg py-1 animate-in fade-in zoom-in-95 duration-100"
style={{ left: clampedX, top: clampedY }} style={{ left: clampedX, top: clampedY }}
> >
@@ -946,6 +1038,23 @@ export function SpaceSidebar() {
updateSpaceLayout(items, folderPayload); updateSpaceLayout(items, folderPayload);
}, [buildLayoutPayload, updateSpaceLayout]); }, [buildLayoutPayload, updateSpaceLayout]);
const handleReorderInFolder = useCallback((folderId: string, reorderedSpaceIds: string[]) => {
const newLayout = resolvedLayout.map(item => {
if (item.type === 'folder' && item.folder.id === folderId) {
const reorderedSpaces = reorderedSpaceIds
.map(id => item.spaces.find(s => s.id === id))
.filter((s): s is TaggedSpace => !!s);
return {
...item,
spaces: reorderedSpaces,
folder: { ...item.folder, spaceIds: reorderedSpaceIds },
};
}
return item;
}) as ResolvedItem[];
persistLayout(newLayout);
}, [resolvedLayout, persistLayout]);
const handleDrop = useCallback((e: React.DragEvent) => { const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -1286,6 +1395,8 @@ export function SpaceSidebar() {
onSpaceContextMenu={handleSpaceContextMenu} onSpaceContextMenu={handleSpaceContextMenu}
onRename={(name) => handleFolderRename(openFolderId, name)} onRename={(name) => handleFolderRename(openFolderId, name)}
onDragStart={(e, spaceId) => handleDragStart(e, spaceId, 'space', openFolderId)} onDragStart={(e, spaceId) => handleDragStart(e, spaceId, 'space', openFolderId)}
onReorder={(ids) => handleReorderInFolder(openFolderId!, ids)}
onParentDragEnd={handleDragEnd}
/> />
); );
})()} })()}