mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-23 23:49:22 +02:00
Compare commits
1 Commits
agent/lamb
...
codex/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9d7402ac7 |
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
72
packages/views/layout/animated-right-sidebar.test.tsx
Normal file
72
packages/views/layout/animated-right-sidebar.test.tsx
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user