Floating sidebar refactor

This commit is contained in:
Gregory Schier
2026-03-12 15:12:49 -07:00
parent cc504e0a1c
commit f7ff964fe5
9 changed files with 163 additions and 99 deletions

View File

@@ -1,28 +1,28 @@
import { useMemo } from 'react';
import { useFloatingSidebarHidden } from '../hooks/useFloatingSidebarHidden';
import { useShouldFloatSidebar } from '../hooks/useShouldFloatSidebar';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { CreateDropdown } from './CreateDropdown';
import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';
export function SidebarActions() {
const floating = useShouldFloatSidebar();
const [normalHidden, setNormalHidden] = useSidebarHidden();
interface Props {
floating?: boolean;
}
export function SidebarActions({ floating = false }: Props) {
const [sidebarHidden, setSidebarHidden] = useSidebarHidden();
const [floatingHidden, setFloatingHidden] = useFloatingSidebarHidden();
const hidden = floating ? floatingHidden : normalHidden;
const hidden = floating ? floatingHidden : sidebarHidden;
const setHidden = useMemo(
() => (floating ? setFloatingHidden : setNormalHidden),
[floating, setFloatingHidden, setNormalHidden],
() => (floating ? setFloatingHidden : setSidebarHidden),
[floating, setFloatingHidden, setSidebarHidden],
);
return (
<HStack className="h-full">
<IconButton
onClick={async () => {
// NOTE: We're not using the (h) => !h pattern here because the data
// might be different if another window changed it (out of sync)
await setHidden(!hidden);
}}
className="pointer-events-auto"

View File

@@ -1,10 +1,10 @@
import { type } from '@tauri-apps/plugin-os';
import { settingsAtom, workspacesAtom } from '@yaakapp-internal/models';
import { HeaderSize, Overlay, SidebarLayout } from '@yaakapp-internal/ui';
import { HeaderSize, SidebarLayout } from '@yaakapp-internal/ui';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import * as m from 'motion/react-m';
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import {
useEnsureActiveCookieJar,
useSubscribeActiveCookieJarId,
@@ -24,7 +24,6 @@ import { useSubscribeRecentCookieJars } from '../hooks/useRecentCookieJars';
import { useSubscribeRecentEnvironments } from '../hooks/useRecentEnvironments';
import { useSubscribeRecentRequests } from '../hooks/useRecentRequests';
import { useSubscribeRecentWorkspaces } from '../hooks/useRecentWorkspaces';
import { useShouldFloatSidebar } from '../hooks/useShouldFloatSidebar';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useSidebarWidth } from '../hooks/useSidebarWidth';
import { useSyncWorkspaceRequestTitle } from '../hooks/useSyncWorkspaceRequestTitle';
@@ -55,11 +54,11 @@ export function Workspace() {
const workspaces = useAtomValue(workspacesAtom);
const settings = useAtomValue(settingsAtom);
const osType = type();
const [width, setWidth, resetWidth] = useSidebarWidth();
const [width, setWidth] = useSidebarWidth();
const [sidebarHidden, setSidebarHidden] = useSidebarHidden();
const [floatingSidebarHidden, setFloatingSidebarHidden] = useFloatingSidebarHidden();
const activeEnvironment = useAtomValue(activeEnvironmentAtom);
const floating = useShouldFloatSidebar();
const [floating, setFloating] = useState(false);
const environmentBgStyle = useMemo(() => {
if (activeEnvironment?.color == null) return undefined;
@@ -89,7 +88,7 @@ export function Workspace() {
className="absolute left-0 right-0 -bottom-[1px] h-[1px] opacity-20"
/>
</div>
<WorkspaceHeader className="pointer-events-none" />
<WorkspaceHeader className="pointer-events-none" floatingSidebar={floating} />
</HeaderSize>
);
@@ -99,7 +98,30 @@ export function Workspace() {
</ErrorBoundary>
);
const sidebarContent = (
const sidebarContent = floating ? (
<div
className={classNames(
'x-theme-sidebar',
'h-full bg-surface border-r border-border-subtle',
'grid grid-rows-[auto_1fr]',
)}
>
<HeaderSize
hideControls
size="lg"
className="border-transparent flex items-center"
osType={osType}
hideWindowControls={settings.hideWindowControls}
useNativeTitlebar={settings.useNativeTitlebar}
interfaceScale={settings.interfaceScale}
>
<SidebarActions floating />
</HeaderSize>
<ErrorBoundary name="Sidebar (Floating)">
<Sidebar />
</ErrorBoundary>
</div>
) : (
<div className="x-theme-sidebar overflow-hidden bg-surface h-full">
<ErrorBoundary name="Sidebar">
<Sidebar className="border-r border-border-subtle" />
@@ -110,52 +132,18 @@ export function Workspace() {
return (
<div className="grid w-full h-full grid-rows-[auto_1fr]">
{header}
{floating ? (
<>
<Overlay
open={!floatingSidebarHidden}
portalName="sidebar"
onClose={() => setFloatingSidebarHidden(true)}
zIndex={20}
>
<m.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
className={classNames(
'x-theme-sidebar',
'absolute top-0 left-0 bottom-0 bg-surface border-r border-border-subtle w-[20rem]',
'grid grid-rows-[auto_1fr]',
)}
>
<HeaderSize
hideControls
size="lg"
className="border-transparent flex items-center"
osType={osType}
hideWindowControls={settings.hideWindowControls}
useNativeTitlebar={settings.useNativeTitlebar}
interfaceScale={settings.interfaceScale}
>
<SidebarActions />
</HeaderSize>
<ErrorBoundary name="Sidebar (Floating)">
<Sidebar />
</ErrorBoundary>
</m.div>
</Overlay>
{workspaceBody}
</>
) : (
<SidebarLayout
width={width ?? 250}
onWidthChange={setWidth}
hidden={sidebarHidden ?? false}
onHiddenChange={(hidden) => setSidebarHidden(hidden)}
sidebar={sidebarContent}
>
{workspaceBody}
</SidebarLayout>
)}
<SidebarLayout
width={width ?? 250}
onWidthChange={setWidth}
hidden={sidebarHidden ?? false}
onHiddenChange={(hidden) => setSidebarHidden(hidden)}
floatingHidden={floatingSidebarHidden ?? true}
onFloatingHiddenChange={(hidden) => setFloatingSidebarHidden(hidden)}
onFloatingChange={setFloating}
sidebar={sidebarContent}
>
{workspaceBody}
</SidebarLayout>
</div>
);
}

View File

@@ -20,9 +20,10 @@ import { WorkspaceActionsDropdown } from './WorkspaceActionsDropdown';
interface Props {
className?: string;
floatingSidebar?: boolean;
}
export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Props) {
export const WorkspaceHeader = memo(function WorkspaceHeader({ className, floatingSidebar }: Props) {
const togglePalette = useToggleCommandPalette();
const [workspaceLayout, setWorkspaceLayout] = useAtom(workspaceLayoutAtom);
const workspace = useAtomValue(activeWorkspaceAtom);
@@ -41,7 +42,7 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop
)}
>
<HStack space={0.5} className={classNames('flex-1 pointer-events-none')}>
<SidebarActions />
<SidebarActions floating={floatingSidebar} />
<CookieDropdown />
<HStack className="min-w-0">
<WorkspaceActionsDropdown />

View File

@@ -1,8 +0,0 @@
import { useWindowSize } from 'react-use';
const WINDOW_FLOATING_SIDEBAR_WIDTH = 600;
export function useShouldFloatSidebar() {
const windowSize = useWindowSize();
return windowSize.width <= WINDOW_FLOATING_SIDEBAR_WIDTH;
}

View File

@@ -1,6 +1,7 @@
import { HeaderSize, SidebarLayout } from '@yaakapp-internal/ui';
import { HeaderSize, IconButton, SidebarLayout } from '@yaakapp-internal/ui';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import { useState } from 'react';
import { useLocalStorage } from 'react-use';
import { useRpcQueryWithEvent } from '../hooks/useRpcQueryWithEvent';
import { getOsType } from '../lib/tauri';
@@ -12,8 +13,15 @@ export function ProxyLayout() {
const os = getOsType();
const exchanges = useAtomValue(filteredExchangesAtom);
const [sidebarWidth, setSidebarWidth] = useLocalStorage('sidebar_width', 250);
const [sidebarHidden, setSidebarHidden] = useLocalStorage('sidebar_hidden', false);
const [floatingSidebarHidden, setFloatingSidebarHidden] = useLocalStorage(
'floating_sidebar_hidden',
true,
);
const [floating, setFloating] = useState(false);
const { data: proxyState } = useRpcQueryWithEvent('get_proxy_state', {}, 'proxy_state_changed');
const isRunning = proxyState?.state === 'running';
const isHidden = floating ? (floatingSidebarHidden ?? true) : (sidebarHidden ?? false);
return (
<div
@@ -30,7 +38,22 @@ export function ProxyLayout() {
interfaceScale={1}
className="x-theme-appHeader bg-surface"
>
<div className="grid grid-cols-[minmax(0,1fr)_auto]">
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto]">
<div className="flex items-center pl-1">
<IconButton
size="sm"
title="Toggle sidebar"
icon={isHidden ? 'left_panel_hidden' : 'left_panel_visible'}
iconColor="secondary"
onClick={() => {
if (floating) {
setFloatingSidebarHidden(!floatingSidebarHidden);
} else {
setSidebarHidden(!sidebarHidden);
}
}}
/>
</div>
<div data-tauri-drag-region className="flex items-center text-sm px-2">
Yaak Proxy
</div>
@@ -61,7 +84,43 @@ export function ProxyLayout() {
<SidebarLayout
width={sidebarWidth ?? 250}
onWidthChange={setSidebarWidth}
sidebar={<Sidebar />}
hidden={sidebarHidden ?? false}
onHiddenChange={setSidebarHidden}
floatingHidden={floatingSidebarHidden ?? true}
onFloatingHiddenChange={setFloatingSidebarHidden}
onFloatingChange={setFloating}
sidebar={
floating ? (
<div
className={classNames(
'x-theme-sidebar',
'h-full bg-surface border-r border-border-subtle',
'grid grid-rows-[auto_1fr]',
)}
>
<HeaderSize
hideControls
size="lg"
className="border-transparent flex items-center pl-1"
osType={os}
hideWindowControls={false}
useNativeTitlebar={false}
interfaceScale={1}
>
<IconButton
size="sm"
title="Toggle sidebar"
icon="left_panel_visible"
iconColor="secondary"
onClick={() => setFloatingSidebarHidden(true)}
/>
</HeaderSize>
<Sidebar />
</div>
) : (
<Sidebar />
)
}
>
<ExchangesTable exchanges={exchanges} className="overflow-auto h-full" />
</SidebarLayout>

View File

@@ -1,5 +1,6 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createStore, Provider } from 'jotai';
import { LazyMotion, MotionConfig } from 'motion/react';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { ProxyLayout } from './components/ProxyLayout';
@@ -26,11 +27,17 @@ listen('model_write', (payload) => {
);
});
const motionFeatures = () => import('framer-motion').then((mod) => mod.domAnimation);
createRoot(document.getElementById('root') as HTMLElement).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<Provider store={jotaiStore}>
<ProxyLayout />
<LazyMotion strict features={motionFeatures}>
<MotionConfig transition={{ duration: 0.1 }}>
<ProxyLayout />
</MotionConfig>
</LazyMotion>
</Provider>
</QueryClientProvider>
</StrictMode>,

View File

@@ -18,6 +18,7 @@
"@yaakapp-internal/ui": "^1.0.0",
"classnames": "^2.5.1",
"jotai": "^2.18.0",
"motion": "^12.4.7",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},

1
package-lock.json generated
View File

@@ -236,6 +236,7 @@
"@yaakapp-internal/ui": "^1.0.0",
"classnames": "^2.5.1",
"jotai": "^2.18.0",
"motion": "^12.4.7",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},

View File

@@ -1,9 +1,14 @@
import classNames from 'classnames';
import * as m from 'motion/react-m';
import type { CSSProperties, ReactNode } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useContainerSize } from '../hooks/useContainerSize';
import { Overlay } from './Overlay';
import type { ResizeHandleEvent } from './ResizeHandle';
import { ResizeHandle } from './ResizeHandle';
const FLOATING_BREAKPOINT = 600;
const side = { gridArea: 'side', minWidth: 0 };
const drag = { gridArea: 'drag' };
const body = { gridArea: 'body', minWidth: 0 };
@@ -13,7 +18,9 @@ interface Props {
onWidthChange: (width: number) => void;
hidden?: boolean;
onHiddenChange?: (hidden: boolean) => void;
floating?: boolean;
floatingHidden?: boolean;
onFloatingHiddenChange?: (hidden: boolean) => void;
onFloatingChange?: (floating: boolean) => void;
floatingWidth?: number;
defaultWidth?: number;
minWidth?: number;
@@ -27,7 +34,9 @@ export function SidebarLayout({
onWidthChange,
hidden = false,
onHiddenChange,
floating = false,
floatingHidden = true,
onFloatingHiddenChange,
onFloatingChange,
floatingWidth = 320,
defaultWidth = 250,
minWidth = 50,
@@ -35,6 +44,14 @@ export function SidebarLayout({
sidebar,
children,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const containerSize = useContainerSize(containerRef);
const floating = containerSize.width > 0 && containerSize.width <= FLOATING_BREAKPOINT;
useEffect(() => {
onFloatingChange?.(floating);
}, [floating]); // eslint-disable-line react-hooks/exhaustive-deps
const [isResizing, setIsResizing] = useState(false);
const startWidth = useRef<number | null>(null);
@@ -81,34 +98,32 @@ export function SidebarLayout({
if (floating) {
return (
<div className={classNames(className, 'relative w-full h-full overflow-hidden')}>
<div ref={containerRef} className={classNames(className, 'w-full h-full min-h-0')}>
<Overlay
open={!floatingHidden}
portalName="sidebar"
onClose={() => onFloatingHiddenChange?.(true)}
zIndex={20}
>
<m.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
style={{ width: floatingWidth }}
className="absolute top-0 left-0 bottom-0"
>
{sidebar}
</m.div>
</Overlay>
{children}
{!hidden && (
<>
<div
className="absolute inset-0 bg-black/50 z-20 transition-opacity"
onClick={() => onHiddenChange?.(true)}
/>
<div
style={{ width: floatingWidth }}
className="absolute top-0 left-0 bottom-0 z-20 animate-slide-in-left"
>
{sidebar}
</div>
</>
)}
</div>
);
}
return (
<div
ref={containerRef}
style={styles}
className={classNames(
className,
'grid w-full h-full',
!isResizing && 'transition-grid',
)}
className={classNames(className, 'grid w-full h-full', !isResizing && 'transition-grid')}
>
<div style={side} className="overflow-hidden">
{sidebar}