Skip to content

Commit a6b22ec

Browse files
Fix project drag clicks and sidebar collapse/scroll behavior
- prevent accidental project toggles after drag operations - switch project rows to Collapsible for stable expand/collapse animation - add optional hidden scrollbar mode to ScrollArea and apply it in SidebarContent
1 parent bb49ebf commit a6b22ec

4 files changed

Lines changed: 32 additions & 89 deletions

File tree

apps/web/src/components/Sidebar.tsx

Lines changed: 19 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import {
6161
} from "./desktopUpdate.logic";
6262
import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
6363
import { Button } from "./ui/button";
64+
import { Collapsible, CollapsibleContent } from "./ui/collapsible";
6465
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
6566
import {
6667
SidebarContent,
@@ -231,18 +232,20 @@ function SortableProjectItem({
231232
const { attributes, listeners, setNodeRef, transform, transition, isDragging, isOver } =
232233
useSortable({ id: projectId });
233234
return (
234-
<div
235+
<li
235236
ref={setNodeRef}
236237
style={{
237238
transform: CSS.Translate.toString(transform),
238239
transition,
239240
}}
240-
className={`rounded-md ${
241+
className={`group/menu-item relative rounded-md ${
241242
isDragging ? "z-20 opacity-80" : ""
242243
} ${isOver && !isDragging ? "ring-1 ring-primary/40" : ""}`}
244+
data-sidebar="menu-item"
245+
data-slot="sidebar-menu-item"
243246
>
244247
{children({ attributes, listeners })}
245-
</div>
248+
</li>
246249
);
247250
}
248251

@@ -294,14 +297,7 @@ export default function Sidebar() {
294297
const renamingCommittedRef = useRef(false);
295298
const renamingInputRef = useRef<HTMLInputElement | null>(null);
296299
const dragInProgressRef = useRef(false);
297-
const suppressProjectClickFromGestureRef = useRef(false);
298-
const suppressProjectClickResetTimerRef = useRef<number | null>(null);
299-
const projectTitlePointerRef = useRef<{
300-
pointerId: number;
301-
startX: number;
302-
startY: number;
303-
moved: boolean;
304-
} | null>(null);
300+
const suppressProjectClickAfterDragRef = useRef(false);
305301
const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null);
306302
const shouldBrowseForProjectImmediately = isElectron;
307303
const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately;
@@ -861,64 +857,20 @@ export default function Sidebar() {
861857

862858
const handleProjectDragStart = useCallback((_event: DragStartEvent) => {
863859
dragInProgressRef.current = true;
860+
suppressProjectClickAfterDragRef.current = true;
864861
}, []);
865862

866863
const handleProjectDragCancel = useCallback((_event: DragCancelEvent) => {
867864
dragInProgressRef.current = false;
868865
}, []);
869866

870-
const handleProjectTitlePointerDownCapture = useCallback(
871-
(event: React.PointerEvent<HTMLButtonElement>) => {
872-
projectTitlePointerRef.current = {
873-
pointerId: event.pointerId,
874-
startX: event.clientX,
875-
startY: event.clientY,
876-
moved: false,
877-
};
878-
},
879-
[],
880-
);
881-
882-
const handleProjectTitlePointerMoveCapture = useCallback(
883-
(event: React.PointerEvent<HTMLButtonElement>) => {
884-
const pointer = projectTitlePointerRef.current;
885-
if (!pointer || pointer.pointerId !== event.pointerId) return;
886-
const movedX = Math.abs(event.clientX - pointer.startX);
887-
const movedY = Math.abs(event.clientY - pointer.startY);
888-
if (movedX > 3 || movedY > 3) {
889-
pointer.moved = true;
890-
}
891-
},
892-
[],
893-
);
894-
895-
const handleProjectTitlePointerUpCapture = useCallback(
896-
(event: React.PointerEvent<HTMLButtonElement>) => {
897-
const pointer = projectTitlePointerRef.current;
898-
if (pointer?.pointerId === event.pointerId) {
899-
if (pointer.moved) {
900-
suppressProjectClickFromGestureRef.current = true;
901-
if (suppressProjectClickResetTimerRef.current !== null) {
902-
window.clearTimeout(suppressProjectClickResetTimerRef.current);
903-
}
904-
suppressProjectClickResetTimerRef.current = window.setTimeout(() => {
905-
suppressProjectClickFromGestureRef.current = false;
906-
suppressProjectClickResetTimerRef.current = null;
907-
}, 0);
908-
}
909-
projectTitlePointerRef.current = null;
910-
}
911-
},
912-
[],
913-
);
914-
915-
const handleProjectTitlePointerCancelCapture = useCallback(() => {
916-
projectTitlePointerRef.current = null;
867+
const handleProjectTitlePointerDownCapture = useCallback(() => {
868+
suppressProjectClickAfterDragRef.current = false;
917869
}, []);
918870

919871
const handleProjectTitleClick = useCallback(
920872
(event: React.MouseEvent<HTMLButtonElement>, projectId: ProjectId) => {
921-
if (dragInProgressRef.current || suppressProjectClickFromGestureRef.current) {
873+
if (dragInProgressRef.current || suppressProjectClickAfterDragRef.current) {
922874
event.preventDefault();
923875
event.stopPropagation();
924876
return;
@@ -940,14 +892,6 @@ export default function Sidebar() {
940892
[toggleProject],
941893
);
942894

943-
useEffect(() => {
944-
return () => {
945-
if (suppressProjectClickResetTimerRef.current !== null) {
946-
window.clearTimeout(suppressProjectClickResetTimerRef.current);
947-
}
948-
};
949-
}, []);
950-
951895
useEffect(() => {
952896
const onWindowKeyDown = (event: KeyboardEvent) => {
953897
const activeThread = routeThreadId
@@ -1318,17 +1262,17 @@ export default function Sidebar() {
13181262
return (
13191263
<SortableProjectItem key={project.id} projectId={project.id}>
13201264
{(dragHandleProps) => (
1321-
<SidebarMenuItem className="group/collapsible">
1265+
<Collapsible
1266+
className="group/collapsible"
1267+
open={project.expanded}
1268+
>
13221269
<div className="group/project-header relative">
13231270
<SidebarMenuButton
13241271
size="sm"
13251272
className="gap-2 px-2 py-1.5 text-left cursor-grab active:cursor-grabbing hover:bg-accent group-hover/project-header:bg-accent group-hover/project-header:text-sidebar-accent-foreground"
13261273
{...dragHandleProps.attributes}
13271274
{...dragHandleProps.listeners}
13281275
onPointerDownCapture={handleProjectTitlePointerDownCapture}
1329-
onPointerMoveCapture={handleProjectTitlePointerMoveCapture}
1330-
onPointerUpCapture={handleProjectTitlePointerUpCapture}
1331-
onPointerCancelCapture={handleProjectTitlePointerCancelCapture}
13321276
onClick={(event) => handleProjectTitleClick(event, project.id)}
13331277
onKeyDown={(event) => handleProjectTitleKeyDown(event, project.id)}
13341278
onContextMenu={(event) => {
@@ -1379,15 +1323,8 @@ export default function Sidebar() {
13791323
</Tooltip>
13801324
</div>
13811325

1382-
<div
1383-
className={`grid transition-[grid-template-rows,opacity] duration-200 ease-out ${
1384-
project.expanded
1385-
? "grid-rows-[1fr] opacity-100"
1386-
: "pointer-events-none grid-rows-[0fr] opacity-0"
1387-
}`}
1388-
>
1389-
<div className="min-h-0 overflow-hidden">
1390-
<SidebarMenuSub className="mx-1 my-0 w-full translate-x-0 gap-0 px-1.5 py-0">
1326+
<CollapsibleContent keepMounted>
1327+
<SidebarMenuSub className="mx-1 my-0 w-full translate-x-0 gap-0 px-1.5 py-0">
13911328
{visibleThreads.map((thread) => {
13921329
const isActive = routeThreadId === thread.id;
13931330
const threadStatus = resolveThreadStatusPill({
@@ -1558,9 +1495,8 @@ export default function Sidebar() {
15581495
</SidebarMenuSubItem>
15591496
)}
15601497
</SidebarMenuSub>
1561-
</div>
1562-
</div>
1563-
</SidebarMenuItem>
1498+
</CollapsibleContent>
1499+
</Collapsible>
15641500
)}
15651501
</SortableProjectItem>
15661502
);

apps/web/src/components/ui/collapsible.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ function CollapsiblePanel({ className, ...props }: CollapsiblePrimitive.Panel.Pr
2222
return (
2323
<CollapsiblePrimitive.Panel
2424
className={cn(
25-
"h-(--collapsible-panel-height) overflow-hidden transition-[height] duration-200 data-ending-style:h-0 data-starting-style:h-0",
25+
"h-(--collapsible-panel-height) overflow-hidden transition-[height] duration-200 data-ending-style:h-0 data-starting-style:h-0 data-open:data-ending-style:[height:var(--collapsible-panel-height)]",
2626
className,
2727
)}
2828
data-slot="collapsible-panel"

apps/web/src/components/ui/scroll-area.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ function ScrollArea({
99
children,
1010
scrollFade = false,
1111
scrollbarGutter = false,
12+
hideScrollbars = false,
1213
...props
1314
}: ScrollAreaPrimitive.Root.Props & {
1415
scrollFade?: boolean;
1516
scrollbarGutter?: boolean;
17+
hideScrollbars?: boolean;
1618
}) {
1719
return (
1820
<ScrollAreaPrimitive.Root className={cn("size-full min-h-0", className)} {...props}>
@@ -22,14 +24,19 @@ function ScrollArea({
2224
scrollFade &&
2325
"mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] [--fade-size:1.5rem]",
2426
scrollbarGutter && "data-has-overflow-y:pe-2.5 data-has-overflow-x:pb-2.5",
27+
hideScrollbars && "[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
2528
)}
2629
data-slot="scroll-area-viewport"
2730
>
2831
{children}
2932
</ScrollAreaPrimitive.Viewport>
30-
<ScrollBar orientation="vertical" />
31-
<ScrollBar orientation="horizontal" />
32-
<ScrollAreaPrimitive.Corner data-slot="scroll-area-corner" />
33+
{!hideScrollbars && (
34+
<>
35+
<ScrollBar orientation="vertical" />
36+
<ScrollBar orientation="horizontal" />
37+
<ScrollAreaPrimitive.Corner data-slot="scroll-area-corner" />
38+
</>
39+
)}
3340
</ScrollAreaPrimitive.Root>
3441
);
3542
}

apps/web/src/components/ui/sidebar.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -651,10 +651,10 @@ function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof S
651651

652652
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
653653
return (
654-
<ScrollArea className="**:data-[slot=scroll-area-scrollbar]:hidden" scrollFade>
654+
<ScrollArea hideScrollbars scrollFade className="h-auto min-h-0 flex-1">
655655
<div
656656
className={cn(
657-
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
657+
"flex w-full min-w-0 flex-col gap-2 group-data-[collapsible=icon]:overflow-hidden",
658658
className,
659659
)}
660660
data-sidebar="content"

0 commit comments

Comments
 (0)