authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-06 11:39:29-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-09 15:08:10-07:00
loga6b22ec1c9b247536111dd2c2e8682f9b3f550c5
treec572c37306f1eaa293a1666a5b3991b29b766d11
parentbb49ebf6e54a078f9937f85de338ac5c8fc4a236

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

4 files changed, 32 insertions(+), 89 deletions(-)

apps/web/src/components/Sidebar.tsx+19-83
......@@ -61,6 +61,7 @@ import {
6161} from "./desktopUpdate.logic";
6262import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
6363import { Button } from "./ui/button";
64import { Collapsible, CollapsibleContent } from "./ui/collapsible";
6465import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
6566import {
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,7 +1262,10 @@ 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"
......@@ -1326,9 +1273,6 @@ export default function Sidebar() {
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+1-1
......@@ -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+10-3
......@@ -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+2-2
......@@ -651,10 +651,10 @@ function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof S
651651
652652function 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"