| author | |
| committer | |
| log | cd713679bbe06c435823bafc48e5bcfa3266a9aa |
| tree | 359824ebfeac38965bc747ac476fc35d9160db1e |
| parent | 9e1fb459a985d13c1d4e57080f49d70dabf3b681 |
| signature | Signed by PGP key B5690EEEBB952194 |
3 files changed, 139 insertions(+), 39 deletions(-)
apps/web/src/components/AppSidebarLayout.tsx+45-37| ... | @@ -15,7 +15,11 @@ import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings" | ... | @@ -15,7 +15,11 @@ import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings" |
| 15 | import { cn, isMacPlatform } from "../lib/utils"; | 15 | import { cn, isMacPlatform } from "../lib/utils"; |
| 16 | import { primaryServerKeybindingsAtom } from "../state/server"; | 16 | import { primaryServerKeybindingsAtom } from "../state/server"; |
| 17 | import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings"; | 17 | import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings"; |
| 18 | import { usePanelAnimationSettings } from "../panelAnimations"; | 18 | import { |
| 19 | PanelAnimationSuppressionProvider, | ||
| 20 | usePanelAnimationSettings, | ||
| 21 | usePanelNavigationSuppression, | ||
| 22 | } from "../panelAnimations"; | ||
| 19 | import LegacyThreadSidebar from "./LegacySidebar"; | 23 | import LegacyThreadSidebar from "./LegacySidebar"; |
| 20 | import ThreadSidebar from "./Sidebar"; | 24 | import ThreadSidebar from "./Sidebar"; |
| 21 | import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; | 25 | import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; |
| ... | @@ -145,6 +149,8 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { | ... | @@ -145,6 +149,8 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { |
| 145 | // Settings routes show the settings nav in place of whichever thread | 149 | // Settings routes show the settings nav in place of whichever thread |
| 146 | // sidebar is active. | 150 | // sidebar is active. |
| 147 | const pathname = useLocation({ select: (location) => location.pathname }); | 151 | const pathname = useLocation({ select: (location) => location.pathname }); |
| 152 | const panelAnimationsSuppressed = usePanelNavigationSuppression(pathname); | ||
| 153 | const routePanelAnimationsActive = panelAnimationsActive && !panelAnimationsSuppressed; | ||
| 148 | const isOnSettings = pathname === "/settings" || pathname.startsWith("/settings/"); | 154 | const isOnSettings = pathname === "/settings" || pathname.startsWith("/settings/"); |
| 149 | const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); | 155 | const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); |
| 150 | const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth); | 156 | const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth); |
| ... | @@ -213,42 +219,44 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { | ... | @@ -213,42 +219,44 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { |
| 213 | }, [navigate, pathname]); | 219 | }, [navigate, pathname]); |
| 214 | 220 | ||
| 215 | return ( | 221 | return ( |
| 216 | <SidebarProvider | 222 | <PanelAnimationSuppressionProvider value={panelAnimationsSuppressed}> |
| 217 | className="h-dvh! min-h-0!" | 223 | <SidebarProvider |
| 218 | data-panel-animations={panelAnimationsActive ? "true" : "false"} | 224 | className="h-dvh! min-h-0!" |
| 219 | defaultOpen | 225 | data-panel-animations={routePanelAnimationsActive ? "true" : "false"} |
| 220 | style={sidebarProviderStyle} | 226 | defaultOpen |
| 221 | > | 227 | style={sidebarProviderStyle} |
| 222 | <ProjectProjectionRetention /> | ||
| 223 | <Sidebar | ||
| 224 | side="left" | ||
| 225 | collapsible="offcanvas" | ||
| 226 | data-app-sidebar="" | ||
| 227 | className="border-r border-sidebar-border bg-sidebar text-sidebar-foreground" | ||
| 228 | resizable={{ | ||
| 229 | maxWidth: sidebarMaximumWidth, | ||
| 230 | minWidth: THREAD_SIDEBAR_MIN_WIDTH, | ||
| 231 | shouldAcceptWidth: ({ currentWidth, nextWidth, wrapper }) => | ||
| 232 | nextWidth <= currentWidth || | ||
| 233 | wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, | ||
| 234 | storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, | ||
| 235 | onResize: setSidebarWidth, | ||
| 236 | }} | ||
| 237 | > | 228 | > |
| 238 | {isOnSettings ? ( | 229 | <ProjectProjectionRetention /> |
| 239 | <> | 230 | <Sidebar |
| 240 | <SidebarChromeHeader isElectron={isElectron} /> | 231 | side="left" |
| 241 | <SettingsSidebarNav pathname={pathname} /> | 232 | collapsible="offcanvas" |
| 242 | </> | 233 | data-app-sidebar="" |
| 243 | ) : legacySidebarEnabled ? ( | 234 | className="border-r border-sidebar-border bg-sidebar text-sidebar-foreground" |
| 244 | <LegacyThreadSidebar /> | 235 | resizable={{ |
| 245 | ) : ( | 236 | maxWidth: sidebarMaximumWidth, |
| 246 | <ThreadSidebar /> | 237 | minWidth: THREAD_SIDEBAR_MIN_WIDTH, |
| 247 | )} | 238 | shouldAcceptWidth: ({ currentWidth, nextWidth, wrapper }) => |
| 248 | <SidebarRail onDoubleClick={resetSidebarWidth} /> | 239 | nextWidth <= currentWidth || |
| 249 | </Sidebar> | 240 | wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, |
| 250 | {children} | 241 | storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, |
| 251 | <SidebarControl /> | 242 | onResize: setSidebarWidth, |
| 252 | </SidebarProvider> | 243 | }} |
| 244 | > | ||
| 245 | {isOnSettings ? ( | ||
| 246 | <> | ||
| 247 | <SidebarChromeHeader isElectron={isElectron} /> | ||
| 248 | <SettingsSidebarNav pathname={pathname} /> | ||
| 249 | </> | ||
| 250 | ) : legacySidebarEnabled ? ( | ||
| 251 | <LegacyThreadSidebar /> | ||
| 252 | ) : ( | ||
| 253 | <ThreadSidebar /> | ||
| 254 | )} | ||
| 255 | <SidebarRail onDoubleClick={resetSidebarWidth} /> | ||
| 256 | </Sidebar> | ||
| 257 | {children} | ||
| 258 | <SidebarControl /> | ||
| 259 | </SidebarProvider> | ||
| 260 | </PanelAnimationSuppressionProvider> | ||
| 253 | ); | 261 | ); |
| 254 | } | 262 | } |
apps/web/src/panelAnimations.test.tsx created+64| ... | @@ -0,0 +1,64 @@ | ||
| 1 | import { act, useLayoutEffect } from "react"; | ||
| 2 | import { create, type ReactTestRenderer } from "react-test-renderer"; | ||
| 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 4 | |||
| 5 | import { usePanelNavigationSuppression } from "./panelAnimations"; | ||
| 6 | |||
| 7 | let renderer: ReactTestRenderer | null = null; | ||
| 8 | let pendingFrames: FrameRequestCallback[] = []; | ||
| 9 | let observed: boolean[] = []; | ||
| 10 | |||
| 11 | function SuppressionProbe({ navigationKey }: { navigationKey: string }) { | ||
| 12 | const suppressed = usePanelNavigationSuppression(navigationKey); | ||
| 13 | useLayoutEffect(() => { | ||
| 14 | observed.push(suppressed); | ||
| 15 | }, [suppressed]); | ||
| 16 | return null; | ||
| 17 | } | ||
| 18 | |||
| 19 | beforeEach(() => { | ||
| 20 | pendingFrames = []; | ||
| 21 | observed = []; | ||
| 22 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | ||
| 23 | vi.stubGlobal("window", { | ||
| 24 | requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => { | ||
| 25 | pendingFrames.push(callback); | ||
| 26 | return pendingFrames.length; | ||
| 27 | }), | ||
| 28 | cancelAnimationFrame: vi.fn(), | ||
| 29 | }); | ||
| 30 | }); | ||
| 31 | |||
| 32 | afterEach(async () => { | ||
| 33 | await act(() => renderer?.unmount()); | ||
| 34 | vi.unstubAllGlobals(); | ||
| 35 | }); | ||
| 36 | |||
| 37 | async function paintPendingFrame() { | ||
| 38 | const callback = pendingFrames.shift(); | ||
| 39 | await act(() => callback?.(0)); | ||
| 40 | } | ||
| 41 | |||
| 42 | describe("usePanelNavigationSuppression", () => { | ||
| 43 | it("suppresses initial and navigated panel state until each route has painted", async () => { | ||
| 44 | await act(() => { | ||
| 45 | renderer = create(<SuppressionProbe navigationKey="/thread/one" />); | ||
| 46 | }); | ||
| 47 | expect(observed.at(-1)).toBe(true); | ||
| 48 | |||
| 49 | await paintPendingFrame(); | ||
| 50 | expect(observed.at(-1)).toBe(true); | ||
| 51 | await paintPendingFrame(); | ||
| 52 | expect(observed.at(-1)).toBe(false); | ||
| 53 | |||
| 54 | await act(() => { | ||
| 55 | renderer?.update(<SuppressionProbe navigationKey="/thread/two" />); | ||
| 56 | }); | ||
| 57 | expect(observed.at(-1)).toBe(true); | ||
| 58 | |||
| 59 | await paintPendingFrame(); | ||
| 60 | expect(observed.at(-1)).toBe(true); | ||
| 61 | await paintPendingFrame(); | ||
| 62 | expect(observed.at(-1)).toBe(false); | ||
| 63 | }); | ||
| 64 | }); | ||
apps/web/src/panelAnimations.ts+30-2| ... | @@ -1,9 +1,36 @@ | ... | @@ -1,9 +1,36 @@ |
| 1 | import { useEffect, useRef, useState } from "react"; | 1 | import { createContext, useContext, useEffect, useRef, useState } from "react"; |
| 2 | import { type PanelAnimationDurationMs } from "@t3tools/contracts/settings"; | 2 | import { type PanelAnimationDurationMs } from "@t3tools/contracts/settings"; |
| 3 | 3 | ||
| 4 | import { useMediaQuery } from "./hooks/useMediaQuery"; | 4 | import { useMediaQuery } from "./hooks/useMediaQuery"; |
| 5 | import { useClientSettings } from "./hooks/useSettings"; | 5 | import { useClientSettings } from "./hooks/useSettings"; |
| 6 | 6 | ||
| 7 | const PanelAnimationSuppressionContext = createContext(false); | ||
| 8 | |||
| 9 | export const PanelAnimationSuppressionProvider = PanelAnimationSuppressionContext.Provider; | ||
| 10 | |||
| 11 | /** | ||
| 12 | * Suppresses panel motion for the first painted frame of an initial route or navigation. | ||
| 13 | * State restored by a route must be visible immediately; later user actions can animate. | ||
| 14 | */ | ||
| 15 | export function usePanelNavigationSuppression(navigationKey: string): boolean { | ||
| 16 | const [paintedNavigationKey, setPaintedNavigationKey] = useState<string | null>(null); | ||
| 17 | const suppressed = paintedNavigationKey !== navigationKey; | ||
| 18 | |||
| 19 | useEffect(() => { | ||
| 20 | if (!suppressed) return; | ||
| 21 | let releaseFrame = 0; | ||
| 22 | const paintFrame = window.requestAnimationFrame(() => { | ||
| 23 | releaseFrame = window.requestAnimationFrame(() => setPaintedNavigationKey(navigationKey)); | ||
| 24 | }); | ||
| 25 | return () => { | ||
| 26 | window.cancelAnimationFrame(paintFrame); | ||
| 27 | window.cancelAnimationFrame(releaseFrame); | ||
| 28 | }; | ||
| 29 | }, [navigationKey, suppressed]); | ||
| 30 | |||
| 31 | return suppressed; | ||
| 32 | } | ||
| 33 | |||
| 7 | export function observeResponsiveBreakpointFade(options: { | 34 | export function observeResponsiveBreakpointFade(options: { |
| 8 | target: HTMLElement; | 35 | target: HTMLElement; |
| 9 | container: HTMLElement; | 36 | container: HTMLElement; |
| ... | @@ -47,7 +74,8 @@ export function usePanelAnimationSettings(): { | ... | @@ -47,7 +74,8 @@ export function usePanelAnimationSettings(): { |
| 47 | } { | 74 | } { |
| 48 | const durationMs = useClientSettings((settings) => settings.panelAnimationDurationMs); | 75 | const durationMs = useClientSettings((settings) => settings.panelAnimationDurationMs); |
| 49 | const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); | 76 | const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); |
| 50 | return { active: durationMs > 0 && !prefersReducedMotion, durationMs }; | 77 | const suppressed = useContext(PanelAnimationSuppressionContext); |
| 78 | return { active: durationMs > 0 && !prefersReducedMotion && !suppressed, durationMs }; | ||
| 51 | } | 79 | } |
| 52 | 80 | ||
| 53 | /** Keeps closing panel content mounted until its opt-in transition ends. */ | 81 | /** Keeps closing panel content mounted until its opt-in transition ends. */ |