feat(deps): react-resizable-panels for docs hub desktop split

Docs hub's desktop sidebar is now drag-resizable. Mobile path is
unchanged — still uses the FolderTreeSidebar Sheet drawer.

- Extracted `FolderTreeBody` from `folder-tree-sidebar.tsx` so the
  same tree renders inside the mobile Sheet AND the desktop panel
  without forking the component.
- `FolderTreeSidebar` is now mobile-only (just the Sheet trigger);
  documents-hub composes the desktop layout itself.
- `<ResizablePanelGroup autoSaveId="documents-hub-split">` persists
  the user's chosen split width via localStorage automatically.
  Min 14% / max 40% defends against starvation.
- shadcn-style `<Resizable*>` primitives in `src/components/ui/`
  match the rest of the UI kit; uses react-resizable-panels v3
  (the v4 release renamed exports to `Group`/`Separator` and broke
  the shadcn convention — pinned v3 for now).

Verified: tsc clean, vitest 1315/1315, next build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 22:30:06 +02:00
parent 4879b17cff
commit 699ae52827
5 changed files with 178 additions and 101 deletions

View File

@@ -25,9 +25,10 @@ import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useDocumentFolders, type FolderNode } from '@/hooks/use-document-folders';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { useUIStore } from '@/stores/ui-store';
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable';
import { FolderActionsMenu } from './folder-actions-menu';
import { FolderBreadcrumb } from './folder-breadcrumb';
import { FolderTreeSidebar } from './folder-tree-sidebar';
import { FolderTreeBody, FolderTreeSidebar } from './folder-tree-sidebar';
import { HubRootView } from './hub-root-view';
import { EntityFolderView } from './entity-folder-view';
import { NewDocumentMenu } from './new-document-menu';
@@ -147,68 +148,104 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) {
setSelectedFolderId(id);
};
return (
<div className="flex flex-col sm:flex-row h-full">
<FolderTreeSidebar
const sidebarFooter = (
<PermissionGate resource="documents" action="manage_folders">
<FolderActionsMenu
selectedFolderId={selectedFolderId}
onSelect={handleFolderSelect}
footer={
<PermissionGate resource="documents" action="manage_folders">
<FolderActionsMenu
selectedFolderId={selectedFolderId}
onAfterDelete={() => handleFolderSelect(undefined)}
/>
</PermissionGate>
}
onAfterDelete={() => handleFolderSelect(undefined)}
/>
<div className="flex-1 min-w-0 p-4 space-y-4">
<div className="flex items-center justify-between gap-3">
<FolderBreadcrumb selectedFolderId={selectedFolderId} onSelect={handleFolderSelect} />
{selectedFolderId !== undefined && (
<NewDocumentMenu
portSlug={portSlug}
folderId={selectedFolderId}
entityType={
isEntityFolder && isEntityType(folderEntityType) ? folderEntityType : undefined
}
entityId={isEntityFolder ? (selectedFolder!.entityId ?? undefined) : undefined}
size="sm"
/>
)}
</div>
</PermissionGate>
);
{selectedFolderId === undefined ? (
<>
<PageHeader
title="Documents"
description="Track signing status, chase pending signers, and audit completion."
actions={<NewDocumentMenu portSlug={portSlug} />}
variant="gradient"
/>
<HubRootView portSlug={portSlug} />
</>
) : isEntityFolder && isEntityType(folderEntityType) ? (
<FolderDropZone
const contentPane = (
<div className="flex-1 min-w-0 p-4 space-y-4">
<div className="flex items-center justify-between gap-3">
<FolderBreadcrumb selectedFolderId={selectedFolderId} onSelect={handleFolderSelect} />
{selectedFolderId !== undefined && (
<NewDocumentMenu
portSlug={portSlug}
folderId={selectedFolderId}
entityType={folderEntityType}
entityId={selectedFolder!.entityId!}
>
<EntityFolderView
portSlug={portSlug}
entityType={folderEntityType}
entityId={selectedFolder!.entityId!}
/>
</FolderDropZone>
) : (
<FolderDropZone folderId={selectedFolderId}>
<FlatFolderListing
key={selectedFolderId ?? 'root'}
portSlug={portSlug}
folderId={selectedFolderId}
/>
</FolderDropZone>
entityType={
isEntityFolder && isEntityType(folderEntityType) ? folderEntityType : undefined
}
entityId={isEntityFolder ? (selectedFolder!.entityId ?? undefined) : undefined}
size="sm"
/>
)}
</div>
{selectedFolderId === undefined ? (
<>
<PageHeader
title="Documents"
description="Track signing status, chase pending signers, and audit completion."
actions={<NewDocumentMenu portSlug={portSlug} />}
variant="gradient"
/>
<HubRootView portSlug={portSlug} />
</>
) : isEntityFolder && isEntityType(folderEntityType) ? (
<FolderDropZone
folderId={selectedFolderId}
entityType={folderEntityType}
entityId={selectedFolder!.entityId!}
>
<EntityFolderView
portSlug={portSlug}
entityType={folderEntityType}
entityId={selectedFolder!.entityId!}
/>
</FolderDropZone>
) : (
<FolderDropZone folderId={selectedFolderId}>
<FlatFolderListing
key={selectedFolderId ?? 'root'}
portSlug={portSlug}
folderId={selectedFolderId}
/>
</FolderDropZone>
)}
</div>
);
return (
<div className="h-full">
{/* Mobile: stacked, with a Sheet drawer for folders. */}
<div className="flex flex-col h-full sm:hidden">
<FolderTreeSidebar
selectedFolderId={selectedFolderId}
onSelect={handleFolderSelect}
footer={sidebarFooter}
/>
{contentPane}
</div>
{/* Desktop (sm+): resizable two-pane split. autoSaveId persists the
* user's chosen split width to localStorage so it survives reloads.
* Min/max defends against the user collapsing the sidebar to zero
* width or starving the content pane. */}
<ResizablePanelGroup
direction="horizontal"
autoSaveId="documents-hub-split"
className="hidden sm:flex h-full"
>
<ResizablePanel defaultSize={20} minSize={14} maxSize={40}>
<aside className="h-full border-r bg-muted/40 p-2 overflow-y-auto">
<div className="mb-2 px-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Folders
</div>
<FolderTreeBody
selectedFolderId={selectedFolderId}
onSelect={handleFolderSelect}
footer={sidebarFooter}
/>
</aside>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={80} minSize={50}>
<div className="h-full overflow-y-auto">{contentPane}</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}