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:
@@ -97,6 +97,7 @@
|
|||||||
"react-email": "^6.1.3",
|
"react-email": "^6.1.3",
|
||||||
"react-hook-form": "^7.75.0",
|
"react-hook-form": "^7.75.0",
|
||||||
"react-image-crop": "^11.0.10",
|
"react-image-crop": "^11.0.10",
|
||||||
|
"react-resizable-panels": "^3.0.6",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
|
|||||||
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
@@ -220,6 +220,9 @@ importers:
|
|||||||
react-image-crop:
|
react-image-crop:
|
||||||
specifier: ^11.0.10
|
specifier: ^11.0.10
|
||||||
version: 11.0.10(react@19.2.6)
|
version: 11.0.10(react@19.2.6)
|
||||||
|
react-resizable-panels:
|
||||||
|
specifier: ^3.0.6
|
||||||
|
version: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
recharts:
|
recharts:
|
||||||
specifier: ^3.8.1
|
specifier: ^3.8.1
|
||||||
version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@18.3.1)(react@19.2.6)(redux@5.0.1)
|
version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@18.3.1)(react@19.2.6)(redux@5.0.1)
|
||||||
@@ -5139,6 +5142,12 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
react-resizable-panels@3.0.6:
|
||||||
|
resolution: {integrity: sha512-b3qKHQ3MLqOgSS+FRYKapNkJZf5EQzuf6+RLiq1/IlTHw99YrZ2NJZLk4hQIzTnnIkRg2LUqyVinu6YWWpUYew==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
|
||||||
react-simple-animate@3.5.3:
|
react-simple-animate@3.5.3:
|
||||||
resolution: {integrity: sha512-Ob+SmB5J1tXDEZyOe2Hf950K4M8VaWBBmQ3cS2BUnTORqHjhK0iKG8fB+bo47ZL15t8d3g/Y0roiqH05UBjG7A==}
|
resolution: {integrity: sha512-Ob+SmB5J1tXDEZyOe2Hf950K4M8VaWBBmQ3cS2BUnTORqHjhK0iKG8fB+bo47ZL15t8d3g/Y0roiqH05UBjG7A==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -10859,6 +10868,11 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.14
|
'@types/react': 19.2.14
|
||||||
|
|
||||||
|
react-resizable-panels@3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.6
|
||||||
|
react-dom: 19.2.6(react@19.2.6)
|
||||||
|
|
||||||
react-simple-animate@3.5.3(react-dom@19.2.6(react@19.2.6)):
|
react-simple-animate@3.5.3(react-dom@19.2.6(react@19.2.6)):
|
||||||
dependencies:
|
dependencies:
|
||||||
react-dom: 19.2.6(react@19.2.6)
|
react-dom: 19.2.6(react@19.2.6)
|
||||||
|
|||||||
@@ -25,9 +25,10 @@ import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
|||||||
import { useDocumentFolders, type FolderNode } from '@/hooks/use-document-folders';
|
import { useDocumentFolders, type FolderNode } from '@/hooks/use-document-folders';
|
||||||
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||||
import { useUIStore } from '@/stores/ui-store';
|
import { useUIStore } from '@/stores/ui-store';
|
||||||
|
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable';
|
||||||
import { FolderActionsMenu } from './folder-actions-menu';
|
import { FolderActionsMenu } from './folder-actions-menu';
|
||||||
import { FolderBreadcrumb } from './folder-breadcrumb';
|
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 { HubRootView } from './hub-root-view';
|
||||||
import { EntityFolderView } from './entity-folder-view';
|
import { EntityFolderView } from './entity-folder-view';
|
||||||
import { NewDocumentMenu } from './new-document-menu';
|
import { NewDocumentMenu } from './new-document-menu';
|
||||||
@@ -147,68 +148,104 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) {
|
|||||||
setSelectedFolderId(id);
|
setSelectedFolderId(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const sidebarFooter = (
|
||||||
<div className="flex flex-col sm:flex-row h-full">
|
<PermissionGate resource="documents" action="manage_folders">
|
||||||
<FolderTreeSidebar
|
<FolderActionsMenu
|
||||||
selectedFolderId={selectedFolderId}
|
selectedFolderId={selectedFolderId}
|
||||||
onSelect={handleFolderSelect}
|
onAfterDelete={() => handleFolderSelect(undefined)}
|
||||||
footer={
|
|
||||||
<PermissionGate resource="documents" action="manage_folders">
|
|
||||||
<FolderActionsMenu
|
|
||||||
selectedFolderId={selectedFolderId}
|
|
||||||
onAfterDelete={() => handleFolderSelect(undefined)}
|
|
||||||
/>
|
|
||||||
</PermissionGate>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0 p-4 space-y-4">
|
</PermissionGate>
|
||||||
<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>
|
|
||||||
|
|
||||||
{selectedFolderId === undefined ? (
|
const contentPane = (
|
||||||
<>
|
<div className="flex-1 min-w-0 p-4 space-y-4">
|
||||||
<PageHeader
|
<div className="flex items-center justify-between gap-3">
|
||||||
title="Documents"
|
<FolderBreadcrumb selectedFolderId={selectedFolderId} onSelect={handleFolderSelect} />
|
||||||
description="Track signing status, chase pending signers, and audit completion."
|
{selectedFolderId !== undefined && (
|
||||||
actions={<NewDocumentMenu portSlug={portSlug} />}
|
<NewDocumentMenu
|
||||||
variant="gradient"
|
portSlug={portSlug}
|
||||||
/>
|
|
||||||
<HubRootView portSlug={portSlug} />
|
|
||||||
</>
|
|
||||||
) : isEntityFolder && isEntityType(folderEntityType) ? (
|
|
||||||
<FolderDropZone
|
|
||||||
folderId={selectedFolderId}
|
folderId={selectedFolderId}
|
||||||
entityType={folderEntityType}
|
entityType={
|
||||||
entityId={selectedFolder!.entityId!}
|
isEntityFolder && isEntityType(folderEntityType) ? folderEntityType : undefined
|
||||||
>
|
}
|
||||||
<EntityFolderView
|
entityId={isEntityFolder ? (selectedFolder!.entityId ?? undefined) : undefined}
|
||||||
portSlug={portSlug}
|
size="sm"
|
||||||
entityType={folderEntityType}
|
/>
|
||||||
entityId={selectedFolder!.entityId!}
|
|
||||||
/>
|
|
||||||
</FolderDropZone>
|
|
||||||
) : (
|
|
||||||
<FolderDropZone folderId={selectedFolderId}>
|
|
||||||
<FlatFolderListing
|
|
||||||
key={selectedFolderId ?? 'root'}
|
|
||||||
portSlug={portSlug}
|
|
||||||
folderId={selectedFolderId}
|
|
||||||
/>
|
|
||||||
</FolderDropZone>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,17 +18,11 @@ interface FolderTreeSidebarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collapsed-by-default tree. Each row shows a chevron that toggles its
|
* Mobile-only Sheet trigger that opens the folder tree in a drawer.
|
||||||
* children; clicking the row label selects the folder. The "All
|
|
||||||
* documents" + "Root" pseudo-rows at the top let reps filter to the
|
|
||||||
* full set or to docs without a folder.
|
|
||||||
*
|
*
|
||||||
* Designed for unlimited depth — only the top level renders by default
|
* Desktop rendering lives in `documents-hub.tsx` as one panel of a
|
||||||
* so deep trees don't blow out the page; reps drill in by expanding.
|
* `<ResizablePanelGroup>` so power users on wide monitors can drag the
|
||||||
*
|
* split. Both surfaces render the same `<FolderTreeBody>` underneath.
|
||||||
* On mobile (< sm) the sidebar collapses into a Sheet drawer triggered by
|
|
||||||
* a "Show folders" button so the main listing isn't pushed below a
|
|
||||||
* full-width folder stack.
|
|
||||||
*/
|
*/
|
||||||
export function FolderTreeSidebar({ selectedFolderId, onSelect, footer }: FolderTreeSidebarProps) {
|
export function FolderTreeSidebar({ selectedFolderId, onSelect, footer }: FolderTreeSidebarProps) {
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
@@ -39,43 +33,32 @@ export function FolderTreeSidebar({ selectedFolderId, onSelect, footer }: Folder
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="sm:hidden px-3 pt-3">
|
||||||
{/* Mobile-only trigger that opens the drawer; hidden at sm+. */}
|
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||||
<div className="sm:hidden px-3 pt-3">
|
<SheetTrigger asChild>
|
||||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
<Button variant="outline" size="sm" className="min-h-[44px]">
|
||||||
<SheetTrigger asChild>
|
<FolderTree className="mr-2 h-4 w-4" />
|
||||||
<Button variant="outline" size="sm" className="min-h-[44px]">
|
Show folders
|
||||||
<FolderTree className="mr-2 h-4 w-4" />
|
</Button>
|
||||||
Show folders
|
</SheetTrigger>
|
||||||
</Button>
|
<SheetContent side="left" className="w-3/4 max-w-xs p-0">
|
||||||
</SheetTrigger>
|
<SheetHeader className="border-b px-3 py-3">
|
||||||
<SheetContent side="left" className="w-3/4 max-w-xs p-0">
|
<SheetTitle className="text-sm">Folders</SheetTitle>
|
||||||
<SheetHeader className="border-b px-3 py-3">
|
</SheetHeader>
|
||||||
<SheetTitle className="text-sm">Folders</SheetTitle>
|
<div className="p-2 overflow-y-auto">
|
||||||
</SheetHeader>
|
<FolderTreeBody
|
||||||
<div className="p-2 overflow-y-auto">
|
selectedFolderId={selectedFolderId}
|
||||||
<TreeBody
|
onSelect={handleMobileSelect}
|
||||||
selectedFolderId={selectedFolderId}
|
footer={footer}
|
||||||
onSelect={handleMobileSelect}
|
/>
|
||||||
footer={footer}
|
</div>
|
||||||
/>
|
</SheetContent>
|
||||||
</div>
|
</Sheet>
|
||||||
</SheetContent>
|
</div>
|
||||||
</Sheet>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop sidebar: hidden on mobile (the Sheet trigger replaces it). */}
|
|
||||||
<aside className="hidden sm:block w-60 shrink-0 border-b sm:border-b-0 sm:border-r bg-muted/40 p-2">
|
|
||||||
<div className="mb-2 px-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
||||||
Folders
|
|
||||||
</div>
|
|
||||||
<TreeBody selectedFolderId={selectedFolderId} onSelect={onSelect} footer={footer} />
|
|
||||||
</aside>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TreeBody({
|
export function FolderTreeBody({
|
||||||
selectedFolderId,
|
selectedFolderId,
|
||||||
onSelect,
|
onSelect,
|
||||||
footer,
|
footer,
|
||||||
|
|||||||
42
src/components/ui/resizable.tsx
Normal file
42
src/components/ui/resizable.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { GripVertical } from 'lucide-react';
|
||||||
|
import * as ResizablePrimitive from 'react-resizable-panels';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const ResizablePanelGroup = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||||
|
<ResizablePrimitive.PanelGroup
|
||||||
|
className={cn('flex h-full w-full data-[panel-group-direction=vertical]:flex-col', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ResizablePanel = ResizablePrimitive.Panel;
|
||||||
|
|
||||||
|
const ResizableHandle = ({
|
||||||
|
withHandle,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||||
|
withHandle?: boolean;
|
||||||
|
}) => (
|
||||||
|
<ResizablePrimitive.PanelResizeHandle
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{withHandle && (
|
||||||
|
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||||
|
<GripVertical className="h-2.5 w-2.5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePrimitive.PanelResizeHandle>
|
||||||
|
);
|
||||||
|
|
||||||
|
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||||
Reference in New Issue
Block a user