| 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 | 15 | import { cn, isMacPlatform } from "../lib/utils"; |
| 16 | 16 | import { primaryServerKeybindingsAtom } from "../state/server"; |
| 17 | 17 | import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings"; |
| 18 | import { usePanelAnimationSettings } from "../panelAnimations"; | |
| 18 | import { | |
| 19 | PanelAnimationSuppressionProvider, | |
| 20 | usePanelAnimationSettings, | |
| 21 | usePanelNavigationSuppression, | |
| 22 | } from "../panelAnimations"; | |
| 19 | 23 | import LegacyThreadSidebar from "./LegacySidebar"; |
| 20 | 24 | import ThreadSidebar from "./Sidebar"; |
| 21 | 25 | import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; |
| ... | ... | @@ -145,6 +149,8 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { |
| 145 | 149 | // Settings routes show the settings nav in place of whichever thread |
| 146 | 150 | // sidebar is active. |
| 147 | 151 | const pathname = useLocation({ select: (location) => location.pathname }); |
| 152 | const panelAnimationsSuppressed = usePanelNavigationSuppression(pathname); | |
| 153 | const routePanelAnimationsActive = panelAnimationsActive && !panelAnimationsSuppressed; | |
| 148 | 154 | const isOnSettings = pathname === "/settings" || pathname.startsWith("/settings/"); |
| 149 | 155 | const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); |
| 150 | 156 | const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth); |
| ... | ... | @@ -213,42 +219,44 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { |
| 213 | 219 | }, [navigate, pathname]); |
| 214 | 220 | |
| 215 | 221 | return ( |
| 216 | <SidebarProvider | |
| 217 | className="h-dvh! min-h-0!" | |
| 218 | data-panel-animations={panelAnimationsActive ? "true" : "false"} | |
| 219 | defaultOpen | |
| 220 | style={sidebarProviderStyle} | |
| 221 | > | |
| 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 | }} | |
| 222 | <PanelAnimationSuppressionProvider value={panelAnimationsSuppressed}> | |
| 223 | <SidebarProvider | |
| 224 | className="h-dvh! min-h-0!" | |
| 225 | data-panel-animations={routePanelAnimationsActive ? "true" : "false"} | |
| 226 | defaultOpen | |
| 227 | style={sidebarProviderStyle} | |
| 237 | 228 | > |
| 238 | {isOnSettings ? ( | |
| 239 | <> | |
| 240 | <SidebarChromeHeader isElectron={isElectron} /> | |
| 241 | <SettingsSidebarNav pathname={pathname} /> | |
| 242 | </> | |
| 243 | ) : legacySidebarEnabled ? ( | |
| 244 | <LegacyThreadSidebar /> | |
| 245 | ) : ( | |
| 246 | <ThreadSidebar /> | |
| 247 | )} | |
| 248 | <SidebarRail onDoubleClick={resetSidebarWidth} /> | |
| 249 | </Sidebar> | |
| 250 | {children} | |
| 251 | <SidebarControl /> | |
| 252 | </SidebarProvider> | |
| 229 | <ProjectProjectionRetention /> | |
| 230 | <Sidebar | |
| 231 | side="left" | |
| 232 | collapsible="offcanvas" | |
| 233 | data-app-sidebar="" | |
| 234 | className="border-r border-sidebar-border bg-sidebar text-sidebar-foreground" | |
| 235 | resizable={{ | |
| 236 | maxWidth: sidebarMaximumWidth, | |
| 237 | minWidth: THREAD_SIDEBAR_MIN_WIDTH, | |
| 238 | shouldAcceptWidth: ({ currentWidth, nextWidth, wrapper }) => | |
| 239 | nextWidth <= currentWidth || | |
| 240 | wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, | |
| 241 | storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, | |
| 242 | onResize: setSidebarWidth, | |
| 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 | import { useEffect, useRef, useState } from "react"; | |
| 1 | import { createContext, useContext, useEffect, useRef, useState } from "react"; | |
| 2 | 2 | import { type PanelAnimationDurationMs } from "@t3tools/contracts/settings"; |
| 3 | 3 | |
| 4 | 4 | import { useMediaQuery } from "./hooks/useMediaQuery"; |
| 5 | 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 | 34 | export function observeResponsiveBreakpointFade(options: { |
| 8 | 35 | target: HTMLElement; |
| 9 | 36 | container: HTMLElement; |
| ... | ... | @@ -47,7 +74,8 @@ export function usePanelAnimationSettings(): { |
| 47 | 74 | } { |
| 48 | 75 | const durationMs = useClientSettings((settings) => settings.panelAnimationDurationMs); |
| 49 | 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 | 81 | /** Keeps closing panel content mounted until its opt-in transition ends. */ |