Compare commits

...

1 Commits

Author SHA1 Message Date
Jiayuan Zhang
d9d7402ac7 fix(views): gate right sidebar motion to toggles 2026-06-19 16:32:22 +08:00
5 changed files with 125 additions and 15 deletions

View File

@@ -220,8 +220,9 @@
/* Right detail sidebars use react-resizable-panels, which sizes panels by
* updating the outer panel's flex-grow. Animate that property for button
* toggles, but disable it while the resize handle is actively dragged. */
[data-right-sidebar-panel] {
* toggles only; mount-time layout restoration and resize sync should snap
* directly to the final state. */
[data-right-sidebar-panel][data-right-sidebar-motion="enabled"] {
transition-property: flex-grow;
transition-duration: 220ms;
transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1);

View File

@@ -89,6 +89,7 @@ import { useT } from "../../i18n";
import { useIssueDetailScrollRestore } from "../hooks/use-issue-detail-scroll-restore";
import {
AnimatedRightSidebar,
getAnimatedRightSidebarInitialOpen,
rightSidebarPanelMotionProps,
useAnimatedRightSidebarState,
} from "../../layout/animated-right-sidebar";
@@ -688,12 +689,17 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
});
const sidebarRef = usePanelRef();
const isMobile = useIsMobile();
const desktopSidebarInitialOpen = getAnimatedRightSidebarInitialOpen(
defaultSidebarOpen,
defaultLayout,
);
const {
open: desktopSidebarOpen,
visualOpen: desktopSidebarVisualOpen,
motionEnabled: desktopSidebarMotionEnabled,
beginToggle: beginDesktopSidebarToggle,
handleResize: handleDesktopSidebarResize,
} = useAnimatedRightSidebarState(defaultSidebarOpen);
} = useAnimatedRightSidebarState(desktopSidebarInitialOpen);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
useEffect(() => {
@@ -1308,8 +1314,10 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
if (!panel) return;
const nextOpen = panel.isCollapsed();
beginDesktopSidebarToggle(nextOpen);
if (nextOpen) panel.expand();
else panel.collapse();
window.requestAnimationFrame(() => {
if (nextOpen) panel.expand();
else panel.collapse();
});
}, [beginDesktopSidebarToggle, isMobile, sidebarRef]);
useIssueDetailScrollRestore({
@@ -2168,7 +2176,8 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
<ResizablePanel
id="sidebar"
{...rightSidebarPanelMotionProps}
defaultSize={defaultSidebarOpen ? 320 : 0}
data-right-sidebar-motion={desktopSidebarMotionEnabled ? "enabled" : undefined}
defaultSize={desktopSidebarOpen ? 320 : 0}
minSize={260}
maxSize={420}
collapsible
@@ -2176,7 +2185,7 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
panelRef={sidebarRef}
onResize={handleDesktopSidebarResize}
>
<AnimatedRightSidebar open={desktopSidebarVisualOpen}>
<AnimatedRightSidebar open={desktopSidebarVisualOpen} motionEnabled={desktopSidebarMotionEnabled}>
{sidebarContent}
</AnimatedRightSidebar>
</ResizablePanel>

View File

@@ -0,0 +1,72 @@
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import {
getAnimatedRightSidebarInitialOpen,
useAnimatedRightSidebarState,
} from "./animated-right-sidebar";
describe("animated right sidebar state", () => {
it("uses a restored collapsed layout before falling back to the default", () => {
expect(
getAnimatedRightSidebarInitialOpen(true, {
content: 100,
sidebar: 0,
}),
).toBe(false);
});
it("uses a restored expanded layout before falling back to the default", () => {
expect(
getAnimatedRightSidebarInitialOpen(false, {
content: 70,
sidebar: 30,
}),
).toBe(true);
});
it("falls back to the caller default when no sidebar layout was restored", () => {
expect(getAnimatedRightSidebarInitialOpen(true, undefined)).toBe(true);
expect(getAnimatedRightSidebarInitialOpen(false, { content: 100 })).toBe(false);
});
it("treats a non-zero layout percentage as open even before pixels are measured", () => {
const { result } = renderHook(() => useAnimatedRightSidebarState(false));
act(() => {
result.current.handleResize({
asPercentage: 30,
inPixels: 0,
});
});
expect(result.current.open).toBe(true);
expect(result.current.visualOpen).toBe(true);
expect(result.current.motionEnabled).toBe(false);
});
it("enables motion only for an explicit toggle window", () => {
vi.useFakeTimers();
try {
const { result } = renderHook(() => useAnimatedRightSidebarState(false));
expect(result.current.motionEnabled).toBe(false);
act(() => {
result.current.beginToggle(true);
});
expect(result.current.open).toBe(true);
expect(result.current.visualOpen).toBe(true);
expect(result.current.motionEnabled).toBe(true);
act(() => {
vi.runAllTimers();
});
expect(result.current.motionEnabled).toBe(false);
} finally {
vi.useRealTimers();
}
});
});

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { motion } from "motion/react";
import type { Layout, PanelSize } from "react-resizable-panels";
import { cn } from "@multica/ui/lib/utils";
export const rightSidebarPanelMotionProps = {
@@ -19,14 +20,29 @@ const rightSidebarTransition = {
mass: 0.8,
} as const;
export function getAnimatedRightSidebarInitialOpen(
defaultOpen: boolean,
defaultLayout: Layout | undefined,
panelId = "sidebar",
) {
const restoredSize = defaultLayout?.[panelId];
return typeof restoredSize === "number" ? restoredSize > 0 : defaultOpen;
}
function isRightSidebarPanelOpen(size: PanelSize) {
return size.asPercentage > 0 || size.inPixels > 0;
}
export function useAnimatedRightSidebarState(initialOpen: boolean) {
const [open, setOpen] = useState(initialOpen);
const [visualOpen, setVisualOpen] = useState(initialOpen);
const [motionEnabled, setMotionEnabled] = useState(false);
const toggleTargetRef = useRef<boolean | null>(null);
const settleTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const beginToggle = useCallback((nextOpen: boolean) => {
toggleTargetRef.current = nextOpen;
setMotionEnabled(true);
setOpen(nextOpen);
setVisualOpen(nextOpen);
@@ -37,11 +53,12 @@ export function useAnimatedRightSidebarState(initialOpen: boolean) {
settleTimeoutRef.current = setTimeout(() => {
toggleTargetRef.current = null;
settleTimeoutRef.current = null;
setMotionEnabled(false);
}, RIGHT_SIDEBAR_PANEL_SETTLE_MS);
}, []);
const handleResize = useCallback((size: { inPixels: number }) => {
const nextOpen = size.inPixels > 0;
const handleResize = useCallback((size: PanelSize) => {
const nextOpen = isRightSidebarPanelOpen(size);
const toggleTarget = toggleTargetRef.current;
if (toggleTarget === null) {
@@ -61,15 +78,17 @@ export function useAnimatedRightSidebarState(initialOpen: boolean) {
};
}, []);
return { open, visualOpen, beginToggle, handleResize };
return { open, visualOpen, motionEnabled, beginToggle, handleResize };
}
export function AnimatedRightSidebar({
open,
motionEnabled,
children,
className,
}: {
open: boolean;
motionEnabled?: boolean;
children: ReactNode;
className?: string;
}) {
@@ -82,7 +101,7 @@ export function AnimatedRightSidebar({
className,
)}
initial={false}
transition={rightSidebarTransition}
transition={motionEnabled ? rightSidebarTransition : { duration: 0 }}
>
<div className="p-4">{children}</div>
</motion.div>

View File

@@ -74,6 +74,7 @@ import { EmojiPicker } from "@multica/ui/components/common/emoji-picker";
import { BreadcrumbHeader } from "../../layout/breadcrumb-header";
import {
AnimatedRightSidebar,
getAnimatedRightSidebarInitialOpen,
rightSidebarPanelMotionProps,
useAnimatedRightSidebarState,
} from "../../layout/animated-right-sidebar";
@@ -465,6 +466,10 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
id: "multica_project_detail_layout",
});
const sidebarRef = usePanelRef();
const desktopSidebarInitialOpen = getAnimatedRightSidebarInitialOpen(
true,
defaultLayout,
);
// Desktop and mobile sidebar state must be separate. A single state defaulting
// to `true` made the mobile <Sheet> mount in the open position on first render
// (after `useIsMobile()` flipped from false→true), briefly covering the page
@@ -472,9 +477,10 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
const {
open: desktopSidebarOpen,
visualOpen: desktopSidebarVisualOpen,
motionEnabled: desktopSidebarMotionEnabled,
beginToggle: beginDesktopSidebarToggle,
handleResize: handleDesktopSidebarResize,
} = useAnimatedRightSidebarState(true);
} = useAnimatedRightSidebarState(desktopSidebarInitialOpen);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const sidebarOpen = isMobile ? mobileSidebarOpen : desktopSidebarOpen;
@@ -494,8 +500,10 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
if (!panel) return;
const nextOpen = panel.isCollapsed();
beginDesktopSidebarToggle(nextOpen);
if (nextOpen) panel.expand();
else panel.collapse();
window.requestAnimationFrame(() => {
if (nextOpen) panel.expand();
else panel.collapse();
});
}, [beginDesktopSidebarToggle, isMobile, sidebarRef]);
// Lead popover
@@ -848,6 +856,7 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
<ResizablePanel
id="sidebar"
{...rightSidebarPanelMotionProps}
data-right-sidebar-motion={desktopSidebarMotionEnabled ? "enabled" : undefined}
defaultSize={desktopSidebarOpen ? 320 : 0}
minSize={260}
maxSize={420}
@@ -856,7 +865,7 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
panelRef={sidebarRef}
onResize={handleDesktopSidebarResize}
>
<AnimatedRightSidebar open={desktopSidebarVisualOpen}>
<AnimatedRightSidebar open={desktopSidebarVisualOpen} motionEnabled={desktopSidebarMotionEnabled}>
{sidebarContent}
</AnimatedRightSidebar>
</ResizablePanel>