mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-23 19:05:16 +02:00
refactor(sidebar): update sidebar resizing logic to use pointer events; enhance drag cursor handling and improve sidebar width persistence
This commit is contained in:
parent
331589275b
commit
147be71238
5 changed files with 208 additions and 166 deletions
|
|
@ -10,18 +10,36 @@ export const SIDEBAR_MAX_WIDTH = 480;
|
||||||
|
|
||||||
interface UseSidebarResizeReturn {
|
interface UseSidebarResizeReturn {
|
||||||
sidebarWidth: number;
|
sidebarWidth: number;
|
||||||
handleMouseDown: (e: React.MouseEvent) => void;
|
handlePointerDown: (e: React.PointerEvent<HTMLElement>) => void;
|
||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setGlobalDragCursor(active: boolean) {
|
||||||
|
const html = document.documentElement;
|
||||||
|
const body = document.body;
|
||||||
|
if (active) {
|
||||||
|
html.style.cursor = "col-resize";
|
||||||
|
body.style.cursor = "col-resize";
|
||||||
|
html.style.userSelect = "none";
|
||||||
|
body.style.userSelect = "none";
|
||||||
|
} else {
|
||||||
|
html.style.cursor = "";
|
||||||
|
body.style.cursor = "";
|
||||||
|
html.style.userSelect = "";
|
||||||
|
body.style.userSelect = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function useSidebarResize(defaultWidth = SIDEBAR_MIN_WIDTH): UseSidebarResizeReturn {
|
export function useSidebarResize(defaultWidth = SIDEBAR_MIN_WIDTH): UseSidebarResizeReturn {
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(defaultWidth);
|
const [sidebarWidth, setSidebarWidth] = useState(defaultWidth);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
const startXRef = useRef(0);
|
const startXRef = useRef(0);
|
||||||
const startWidthRef = useRef(defaultWidth);
|
const startWidthRef = useRef(defaultWidth);
|
||||||
|
const widthRef = useRef(defaultWidth);
|
||||||
|
const pointerIdRef = useRef<number | null>(null);
|
||||||
|
const captureTargetRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
// Initialize from cookie on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const match = document.cookie.match(/(?:^|; )sidebar_width=([^;]+)/);
|
const match = document.cookie.match(/(?:^|; )sidebar_width=([^;]+)/);
|
||||||
|
|
@ -29,14 +47,13 @@ export function useSidebarResize(defaultWidth = SIDEBAR_MIN_WIDTH): UseSidebarRe
|
||||||
const parsed = Number(match[1]);
|
const parsed = Number(match[1]);
|
||||||
if (!Number.isNaN(parsed) && parsed >= SIDEBAR_MIN_WIDTH && parsed <= SIDEBAR_MAX_WIDTH) {
|
if (!Number.isNaN(parsed) && parsed >= SIDEBAR_MIN_WIDTH && parsed <= SIDEBAR_MAX_WIDTH) {
|
||||||
setSidebarWidth(parsed);
|
setSidebarWidth(parsed);
|
||||||
|
widthRef.current = parsed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore cookie read errors
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Persist width to cookie
|
|
||||||
const persistWidth = useCallback((width: number) => {
|
const persistWidth = useCallback((width: number) => {
|
||||||
try {
|
try {
|
||||||
// biome-ignore lint/suspicious/noDocumentCookie: SSR-readable preference, not security-sensitive
|
// biome-ignore lint/suspicious/noDocumentCookie: SSR-readable preference, not security-sensitive
|
||||||
|
|
@ -46,57 +63,81 @@ export function useSidebarResize(defaultWidth = SIDEBAR_MIN_WIDTH): UseSidebarRe
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleMouseDown = useCallback(
|
const releaseCapture = useCallback(() => {
|
||||||
(e: React.MouseEvent) => {
|
const target = captureTargetRef.current;
|
||||||
e.preventDefault();
|
const pointerId = pointerIdRef.current;
|
||||||
startXRef.current = e.clientX;
|
if (target && pointerId !== null) {
|
||||||
startWidthRef.current = sidebarWidth;
|
try {
|
||||||
setIsDragging(true);
|
if (target.hasPointerCapture(pointerId)) {
|
||||||
|
target.releasePointerCapture(pointerId);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
captureTargetRef.current = null;
|
||||||
|
pointerIdRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
document.body.style.cursor = "col-resize";
|
const handlePointerDown = useCallback(
|
||||||
document.body.style.userSelect = "none";
|
(e: React.PointerEvent<HTMLElement>) => {
|
||||||
|
if (e.pointerType === "mouse" && e.button !== 0) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
const target = e.currentTarget;
|
||||||
|
try {
|
||||||
|
target.setPointerCapture(e.pointerId);
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
captureTargetRef.current = target;
|
||||||
|
pointerIdRef.current = e.pointerId;
|
||||||
|
startXRef.current = e.clientX;
|
||||||
|
startWidthRef.current = widthRef.current;
|
||||||
|
setIsDragging(true);
|
||||||
|
setGlobalDragCursor(true);
|
||||||
},
|
},
|
||||||
[sidebarWidth]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDragging) return;
|
if (!isDragging) return;
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
const handlePointerMove = (e: PointerEvent) => {
|
||||||
|
if (pointerIdRef.current !== null && e.pointerId !== pointerIdRef.current) return;
|
||||||
const delta = e.clientX - startXRef.current;
|
const delta = e.clientX - startXRef.current;
|
||||||
const newWidth = Math.min(
|
const newWidth = Math.min(
|
||||||
SIDEBAR_MAX_WIDTH,
|
SIDEBAR_MAX_WIDTH,
|
||||||
Math.max(SIDEBAR_MIN_WIDTH, startWidthRef.current + delta)
|
Math.max(SIDEBAR_MIN_WIDTH, startWidthRef.current + delta)
|
||||||
);
|
);
|
||||||
setSidebarWidth(newWidth);
|
if (newWidth !== widthRef.current) {
|
||||||
|
widthRef.current = newWidth;
|
||||||
|
setSidebarWidth(newWidth);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const stop = (e: PointerEvent) => {
|
||||||
|
if (pointerIdRef.current !== null && e.pointerId !== pointerIdRef.current) return;
|
||||||
|
releaseCapture();
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
document.body.style.cursor = "";
|
setGlobalDragCursor(false);
|
||||||
document.body.style.userSelect = "";
|
persistWidth(widthRef.current);
|
||||||
|
|
||||||
// Persist the final width
|
|
||||||
setSidebarWidth((currentWidth) => {
|
|
||||||
persistWidth(currentWidth);
|
|
||||||
return currentWidth;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("mousemove", handleMouseMove);
|
window.addEventListener("pointermove", handlePointerMove);
|
||||||
document.addEventListener("mouseup", handleMouseUp);
|
window.addEventListener("pointerup", stop);
|
||||||
|
window.addEventListener("pointercancel", stop);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("mousemove", handleMouseMove);
|
window.removeEventListener("pointermove", handlePointerMove);
|
||||||
document.removeEventListener("mouseup", handleMouseUp);
|
window.removeEventListener("pointerup", stop);
|
||||||
document.body.style.cursor = "";
|
window.removeEventListener("pointercancel", stop);
|
||||||
document.body.style.userSelect = "";
|
setGlobalDragCursor(false);
|
||||||
|
releaseCapture();
|
||||||
};
|
};
|
||||||
}, [isDragging, persistWidth]);
|
}, [isDragging, persistWidth, releaseCapture]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sidebarWidth,
|
sidebarWidth,
|
||||||
handleMouseDown,
|
handlePointerDown,
|
||||||
isDragging,
|
isDragging,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,11 @@ import type { InboxItem } from "@/hooks/use-inbox";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { SidebarProvider, useSidebarState } from "../../hooks";
|
import { SidebarProvider, useSidebarState } from "../../hooks";
|
||||||
import { useSidebarResize } from "../../hooks/useSidebarResize";
|
import {
|
||||||
|
SIDEBAR_MAX_WIDTH,
|
||||||
|
SIDEBAR_MIN_WIDTH,
|
||||||
|
useSidebarResize,
|
||||||
|
} from "../../hooks/useSidebarResize";
|
||||||
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
|
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
|
||||||
import { Header } from "../header";
|
import { Header } from "../header";
|
||||||
import { IconRail } from "../icon-rail";
|
import { IconRail } from "../icon-rail";
|
||||||
|
|
@ -25,7 +29,6 @@ import {
|
||||||
MobileSidebarTrigger,
|
MobileSidebarTrigger,
|
||||||
Sidebar,
|
Sidebar,
|
||||||
} from "../sidebar";
|
} from "../sidebar";
|
||||||
import { SidebarCollapseButton } from "../sidebar/SidebarCollapseButton";
|
|
||||||
import { SidebarSlideOutPanel } from "../sidebar/SidebarSlideOutPanel";
|
import { SidebarSlideOutPanel } from "../sidebar/SidebarSlideOutPanel";
|
||||||
import { TabBar } from "../tabs/TabBar";
|
import { TabBar } from "../tabs/TabBar";
|
||||||
|
|
||||||
|
|
@ -123,13 +126,11 @@ function MainContentPanel({
|
||||||
isChatPage,
|
isChatPage,
|
||||||
onTabSwitch,
|
onTabSwitch,
|
||||||
onNewChat,
|
onNewChat,
|
||||||
leftActions,
|
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
isChatPage: boolean;
|
isChatPage: boolean;
|
||||||
onTabSwitch?: (tab: Tab) => void;
|
onTabSwitch?: (tab: Tab) => void;
|
||||||
onNewChat?: () => void;
|
onNewChat?: () => void;
|
||||||
leftActions?: React.ReactNode;
|
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const activeTab = useAtomValue(activeTabAtom);
|
const activeTab = useAtomValue(activeTabAtom);
|
||||||
|
|
@ -140,7 +141,6 @@ function MainContentPanel({
|
||||||
<TabBar
|
<TabBar
|
||||||
onTabSwitch={onTabSwitch}
|
onTabSwitch={onTabSwitch}
|
||||||
onNewChat={onNewChat}
|
onNewChat={onNewChat}
|
||||||
leftActions={leftActions}
|
|
||||||
rightActions={<RightPanelExpandButton />}
|
rightActions={<RightPanelExpandButton />}
|
||||||
className="min-w-0"
|
className="min-w-0"
|
||||||
/>
|
/>
|
||||||
|
|
@ -214,7 +214,7 @@ export function LayoutShell({
|
||||||
const { isCollapsed, setIsCollapsed, toggleCollapsed } = useSidebarState(defaultCollapsed);
|
const { isCollapsed, setIsCollapsed, toggleCollapsed } = useSidebarState(defaultCollapsed);
|
||||||
const {
|
const {
|
||||||
sidebarWidth,
|
sidebarWidth,
|
||||||
handleMouseDown: onResizeMouseDown,
|
handlePointerDown: onResizePointerDown,
|
||||||
isDragging: isResizing,
|
isDragging: isResizing,
|
||||||
} = useSidebarResize();
|
} = useSidebarResize();
|
||||||
|
|
||||||
|
|
@ -382,10 +382,7 @@ export function LayoutShell({
|
||||||
onSearchSpaceDelete={onSearchSpaceDelete}
|
onSearchSpaceDelete={onSearchSpaceDelete}
|
||||||
onSearchSpaceSettings={onSearchSpaceSettings}
|
onSearchSpaceSettings={onSearchSpaceSettings}
|
||||||
onAddSearchSpace={onAddSearchSpace}
|
onAddSearchSpace={onAddSearchSpace}
|
||||||
isSingleRailMode={isCollapsed}
|
isSingleRailMode={false}
|
||||||
onNewChat={onNewChat}
|
|
||||||
navItems={navItems}
|
|
||||||
onNavItemClick={onNavItemClick}
|
|
||||||
user={user}
|
user={user}
|
||||||
onUserSettings={onUserSettings}
|
onUserSettings={onUserSettings}
|
||||||
onAnnouncements={onAnnouncements}
|
onAnnouncements={onAnnouncements}
|
||||||
|
|
@ -397,58 +394,54 @@ export function LayoutShell({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar + slide-out panels share one container; overflow visible so panels can overlay main content. Negative right margin closes the flex gap so the sidebar sits flush against the main panel, separated only by a border. */}
|
{/* Sidebar + slide-out panels share one container; overflow visible so panels can overlay main content. Negative right margin closes the flex gap so the sidebar sits flush against the main panel, separated only by a border. */}
|
||||||
<div
|
<div className="relative hidden md:flex shrink-0 z-20 -mr-2 border-r bg-panel">
|
||||||
className={cn(
|
<Sidebar
|
||||||
"relative hidden md:flex shrink-0 z-20 -mr-2 border-r",
|
searchSpace={searchSpace}
|
||||||
!isCollapsed && "bg-panel"
|
isCollapsed={isCollapsed}
|
||||||
)}
|
onToggleCollapse={toggleCollapsed}
|
||||||
>
|
navItems={navItems}
|
||||||
{!isCollapsed && (
|
onNavItemClick={onNavItemClick}
|
||||||
<Sidebar
|
chats={chats}
|
||||||
searchSpace={searchSpace}
|
sharedChats={sharedChats}
|
||||||
isCollapsed={isCollapsed}
|
activeChatId={activeChatId}
|
||||||
onToggleCollapse={toggleCollapsed}
|
onNewChat={onNewChat}
|
||||||
navItems={navItems}
|
onChatSelect={onChatSelect}
|
||||||
onNavItemClick={onNavItemClick}
|
onChatRename={onChatRename}
|
||||||
chats={chats}
|
onChatDelete={onChatDelete}
|
||||||
sharedChats={sharedChats}
|
onChatArchive={onChatArchive}
|
||||||
activeChatId={activeChatId}
|
onViewAllSharedChats={onViewAllSharedChats}
|
||||||
onNewChat={onNewChat}
|
onViewAllPrivateChats={onViewAllPrivateChats}
|
||||||
onChatSelect={onChatSelect}
|
isSharedChatsPanelOpen={activeSlideoutPanel === "shared"}
|
||||||
onChatRename={onChatRename}
|
isPrivateChatsPanelOpen={activeSlideoutPanel === "private"}
|
||||||
onChatDelete={onChatDelete}
|
user={user}
|
||||||
onChatArchive={onChatArchive}
|
onSettings={onSettings}
|
||||||
onViewAllSharedChats={onViewAllSharedChats}
|
onManageMembers={onManageMembers}
|
||||||
onViewAllPrivateChats={onViewAllPrivateChats}
|
onUserSettings={onUserSettings}
|
||||||
isSharedChatsPanelOpen={activeSlideoutPanel === "shared"}
|
onAnnouncements={onAnnouncements}
|
||||||
isPrivateChatsPanelOpen={activeSlideoutPanel === "private"}
|
announcementUnreadCount={announcementUnreadCount}
|
||||||
user={user}
|
onLogout={onLogout}
|
||||||
onSettings={onSettings}
|
pageUsage={pageUsage}
|
||||||
onManageMembers={onManageMembers}
|
theme={theme}
|
||||||
onUserSettings={onUserSettings}
|
setTheme={setTheme}
|
||||||
onAnnouncements={onAnnouncements}
|
renderUserProfile={false}
|
||||||
announcementUnreadCount={announcementUnreadCount}
|
className="flex shrink-0"
|
||||||
onLogout={onLogout}
|
isLoadingChats={isLoadingChats}
|
||||||
pageUsage={pageUsage}
|
sidebarWidth={sidebarWidth}
|
||||||
theme={theme}
|
isResizing={isResizing}
|
||||||
setTheme={setTheme}
|
/>
|
||||||
renderUserProfile={false}
|
|
||||||
className="flex shrink-0"
|
|
||||||
isLoadingChats={isLoadingChats}
|
|
||||||
sidebarWidth={sidebarWidth}
|
|
||||||
isResizing={isResizing}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Drag hit-area straddling the right border — wider for a forgiving grab,
|
|
||||||
z-50 + pointer-events-auto to beat any neighboring stacking context. */}
|
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<button
|
<hr
|
||||||
type="button"
|
aria-orientation="vertical"
|
||||||
aria-label="Resize sidebar"
|
aria-label="Resize sidebar"
|
||||||
onMouseDown={onResizeMouseDown}
|
aria-valuemin={SIDEBAR_MIN_WIDTH}
|
||||||
|
aria-valuemax={SIDEBAR_MAX_WIDTH}
|
||||||
|
aria-valuenow={sidebarWidth}
|
||||||
|
tabIndex={0}
|
||||||
|
onPointerDown={onResizePointerDown}
|
||||||
|
style={{ touchAction: "none" }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute top-0 right-0 h-full w-4 translate-x-1/2 cursor-col-resize z-50 pointer-events-auto select-none bg-transparent border-0 p-0 focus:outline-none",
|
"absolute top-0 right-0 h-full w-4 translate-x-1/2 z-50 select-none cursor-col-resize",
|
||||||
"after:content-[''] after:absolute after:inset-y-0 after:left-1/2 after:w-px after:-translate-x-1/2 after:bg-transparent hover:after:bg-border/80 after:transition-colors",
|
"after:content-[''] after:absolute after:inset-y-0 after:left-1/2 after:w-px after:-translate-x-1/2 after:bg-transparent hover:after:bg-border/80 after:transition-colors",
|
||||||
isResizing && "after:bg-border"
|
isResizing && "after:bg-border"
|
||||||
)}
|
)}
|
||||||
|
|
@ -518,11 +511,6 @@ export function LayoutShell({
|
||||||
isChatPage={isChatPage}
|
isChatPage={isChatPage}
|
||||||
onTabSwitch={onTabSwitch}
|
onTabSwitch={onTabSwitch}
|
||||||
onNewChat={onNewChat}
|
onNewChat={onNewChat}
|
||||||
leftActions={
|
|
||||||
isCollapsed ? (
|
|
||||||
<SidebarCollapseButton isCollapsed={isCollapsed} onToggle={toggleCollapsed} />
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</MainContentPanel>
|
</MainContentPanel>
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,7 @@ function CollapsedOverlay({ item }: { item: NavItem }) {
|
||||||
|
|
||||||
export function NavSection({ items, onItemClick, isCollapsed = false }: NavSectionProps) {
|
export function NavSection({ items, onItemClick, isCollapsed = false }: NavSectionProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col gap-0.5 py-2", isCollapsed && "items-center")}>
|
<div className="flex flex-col gap-0.5 py-2">
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const { tooltip } = getStatusInfo(item.statusIndicator);
|
const { tooltip } = getStatusInfo(item.statusIndicator);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -113,45 +113,43 @@ export function Sidebar({
|
||||||
[navItems]
|
[navItems]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const collapsedWidth = 51;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex h-full flex-col bg-panel text-sidebar-foreground overflow-hidden select-none",
|
"relative flex h-full flex-col bg-panel text-sidebar-foreground overflow-hidden select-none",
|
||||||
isCollapsed ? "w-[60px] transition-[width] duration-200" : "",
|
!isResizing && "transition-[width] duration-200 ease-out",
|
||||||
!isCollapsed && !isResizing ? "transition-[width] duration-200" : "",
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
style={!isCollapsed ? { width: sidebarWidth } : undefined}
|
style={{ width: isCollapsed ? collapsedWidth : sidebarWidth }}
|
||||||
>
|
>
|
||||||
{/* Header - search space name or collapse button when collapsed */}
|
<div className="flex h-12 shrink-0 items-center gap-0 px-1 border-b">
|
||||||
{isCollapsed ? (
|
<div
|
||||||
<div className="flex h-12 shrink-0 items-center justify-center border-b">
|
className={cn(
|
||||||
|
"min-w-0 overflow-hidden",
|
||||||
|
"transition-[max-width,opacity,margin-right] duration-200 ease-out",
|
||||||
|
isCollapsed ? "max-w-0 opacity-0 mr-0" : "max-w-[400px] flex-1 opacity-100"
|
||||||
|
)}
|
||||||
|
aria-hidden={isCollapsed}
|
||||||
|
>
|
||||||
|
<SidebarHeader
|
||||||
|
searchSpace={searchSpace}
|
||||||
|
isCollapsed={false}
|
||||||
|
onSettings={onSettings}
|
||||||
|
onManageMembers={onManageMembers}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={cn("shrink-0", isCollapsed && "mx-auto")}>
|
||||||
<SidebarCollapseButton
|
<SidebarCollapseButton
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
onToggle={onToggleCollapse ?? (() => {})}
|
onToggle={onToggleCollapse ?? (() => {})}
|
||||||
disableTooltip={disableTooltips}
|
disableTooltip={disableTooltips}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<div className="flex h-12 shrink-0 items-center gap-0 px-1 border-b">
|
|
||||||
<SidebarHeader
|
|
||||||
searchSpace={searchSpace}
|
|
||||||
isCollapsed={isCollapsed}
|
|
||||||
onSettings={onSettings}
|
|
||||||
onManageMembers={onManageMembers}
|
|
||||||
/>
|
|
||||||
<div className="shrink-0">
|
|
||||||
<SidebarCollapseButton
|
|
||||||
isCollapsed={isCollapsed}
|
|
||||||
onToggle={onToggleCollapse ?? (() => {})}
|
|
||||||
disableTooltip={disableTooltips}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* New chat button + Inbox */}
|
<div className="flex flex-col gap-0.5 py-1.5">
|
||||||
<div className={cn("flex flex-col gap-0.5 py-1.5", isCollapsed && "items-center")}>
|
|
||||||
<SidebarButton
|
<SidebarButton
|
||||||
icon={SquarePen}
|
icon={SquarePen}
|
||||||
label={t("new_chat")}
|
label={t("new_chat")}
|
||||||
|
|
@ -177,7 +175,7 @@ export function Sidebar({
|
||||||
|
|
||||||
{/* Chat sections - fills available space */}
|
{/* Chat sections - fills available space */}
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<div className="flex-1 w-[60px]" />
|
<div className="flex-1 w-full" />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex flex-col gap-1 pt-2 w-full min-h-0 overflow-hidden">
|
<div className="flex-1 flex flex-col gap-1 pt-2 w-full min-h-0 overflow-hidden">
|
||||||
{/* Shared Chats Section - takes only space needed, max 50% */}
|
{/* Shared Chats Section - takes only space needed, max 50% */}
|
||||||
|
|
|
||||||
|
|
@ -27,15 +27,9 @@ interface SidebarButtonProps {
|
||||||
buttonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
|
buttonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const expandedClassName = cn(
|
const baseClassName = cn(
|
||||||
"flex items-center gap-2 rounded-md mx-2 px-2 py-1.5 text-sm transition-colors text-left",
|
"group/sidebar-button relative flex h-9 items-center rounded-md mx-2 px-2 text-sm text-left",
|
||||||
"hover:bg-accent hover:text-accent-foreground",
|
"transition-colors hover:bg-accent hover:text-accent-foreground",
|
||||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
||||||
);
|
|
||||||
|
|
||||||
const collapsedClassName = cn(
|
|
||||||
"relative flex h-10 w-10 items-center justify-center rounded-md transition-colors",
|
|
||||||
"hover:bg-accent hover:text-accent-foreground",
|
|
||||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -56,53 +50,67 @@ export function SidebarButton({
|
||||||
}: SidebarButtonProps) {
|
}: SidebarButtonProps) {
|
||||||
const activeClassName = "bg-accent text-accent-foreground";
|
const activeClassName = "bg-accent text-accent-foreground";
|
||||||
|
|
||||||
if (isCollapsed) {
|
const iconNode = isCollapsed
|
||||||
return (
|
? (collapsedIconNode ?? <Icon className="h-3.5 w-3.5" />)
|
||||||
<Tooltip>
|
: (expandedIconNode ?? <Icon className="h-3.5 w-3.5 shrink-0" />);
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
className={cn(collapsedClassName, isActive && activeClassName, className)}
|
|
||||||
{...buttonProps}
|
|
||||||
>
|
|
||||||
{collapsedIconNode ?? <Icon className="h-3.5 w-3.5" />}
|
|
||||||
{collapsedOverlay}
|
|
||||||
<span className="sr-only">{label}</span>
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right" className="max-w-xs">
|
|
||||||
{tooltipContent ?? (
|
|
||||||
<>
|
|
||||||
{label}
|
|
||||||
{typeof badge === "string" && ` (${badge})`}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const button = (
|
const button = (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(expandedClassName, isActive && activeClassName, className)}
|
aria-label={isCollapsed ? label : undefined}
|
||||||
|
className={cn(baseClassName, isActive && activeClassName, className)}
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
>
|
>
|
||||||
{expandedIconNode ?? <Icon className="h-3.5 w-3.5 shrink-0" />}
|
<span
|
||||||
<span className="flex-1 truncate">{label}</span>
|
className={cn(
|
||||||
{trailingContent}
|
"flex min-w-0 items-center translate-x-0.5 transition-transform duration-200 ease-out",
|
||||||
{badge && typeof badge !== "string" ? badge : null}
|
isCollapsed ? "shrink-0" : "flex-1"
|
||||||
{badge && typeof badge === "string" ? (
|
)}
|
||||||
<span className="inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-medium">
|
>
|
||||||
|
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||||
|
{iconNode}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 overflow-hidden whitespace-nowrap text-left",
|
||||||
|
"transition-[max-width,opacity,margin-left] duration-200 ease-out",
|
||||||
|
isCollapsed
|
||||||
|
? "max-w-0 opacity-0 ml-0"
|
||||||
|
: "max-w-[260px] flex-1 opacity-100 ml-2"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="block truncate">{label}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{!isCollapsed && trailingContent}
|
||||||
|
{!isCollapsed && badge && typeof badge !== "string" ? badge : null}
|
||||||
|
{!isCollapsed && badge && typeof badge === "string" ? (
|
||||||
|
<span className="ml-1 inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-medium">
|
||||||
{badge}
|
{badge}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{collapsedOverlay && (
|
||||||
|
<span
|
||||||
|
aria-hidden={!isCollapsed}
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute inset-0 transition-opacity duration-150",
|
||||||
|
isCollapsed ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{collapsedOverlay}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="sr-only">{label}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!tooltipContent) {
|
const renderTooltip = isCollapsed || !!tooltipContent;
|
||||||
|
if (!renderTooltip) {
|
||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,7 +118,14 @@ export function SidebarButton({
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||||
<TooltipContent side="right" className="max-w-xs">
|
<TooltipContent side="right" className="max-w-xs">
|
||||||
{tooltipContent}
|
{isCollapsed
|
||||||
|
? (tooltipContent ?? (
|
||||||
|
<>
|
||||||
|
{label}
|
||||||
|
{typeof badge === "string" && ` (${badge})`}
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
: tooltipContent}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue