Skip to content

Commit d1c6f22

Browse files
committed
feat/implement sidebar v1
1 parent 3d5f820 commit d1c6f22

File tree

11 files changed

+869
-132
lines changed

11 files changed

+869
-132
lines changed

public/components/sidebar.png

34 KB
Loading

public/r/sidebar.json

Lines changed: 40 additions & 0 deletions
Large diffs are not rendered by default.

registry.json

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,40 @@
5959
"target": "components/doras-ui/tile.tsx"
6060
}
6161
]
62+
},
63+
{
64+
"name": "sidebar",
65+
"type": "registry:block",
66+
"title": "Sidebar",
67+
"author": "Tommy Lundy <[email protected]>",
68+
"description": "Advanced sidebar component that's collapsible, nestable, and built on store",
69+
"categories": ["general"],
70+
"dependencies": [
71+
"@tanstack/react-store",
72+
"class-variance-authority",
73+
"@tabler/icons-react"
74+
],
75+
"registryDependencies": [
76+
"button",
77+
"sheet",
78+
"tooltip",
79+
"popover"
80+
],
81+
"files": [
82+
{
83+
"path": "registry/sidebar/sidebar.tsx",
84+
"type": "registry:component",
85+
"target": "components/doras-ui/sidebar.tsx"
86+
},
87+
{
88+
"path": "registry/sidebar/sidebar-store.ts",
89+
"type": "registry:lib"
90+
},
91+
{
92+
"path": "registry/sidebar/sidebar-script.tsx",
93+
"type": "registry:lib"
94+
}
95+
]
6296
}
6397
]
6498
}

registry/sidebar/sidebar-store.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export interface SidebarState {
66
variant: "default" | "floating" | "inset";
77
side: "left" | "right";
88
keyboardShortcut?: string;
9+
activeItem?: string;
910
}
1011

1112
export interface SidebarStoreState {
@@ -182,6 +183,21 @@ export const sidebarActions = {
182183
});
183184
},
184185

186+
setActiveItem: (id: string, item: string) => {
187+
sidebarStore.setState((state) => {
188+
const sidebar = state.sidebars[id];
189+
if (!sidebar) return state;
190+
191+
return {
192+
sidebars: {
193+
...state.sidebars,
194+
[id]: { ...sidebar, activeItem: item },
195+
},
196+
keyboardShortcuts: state.keyboardShortcuts,
197+
};
198+
});
199+
},
200+
185201
// Toggle sidebar by keyboard shortcut
186202
toggleByShortcut: (shortcut: string, isMobile = false) => {
187203
const sidebarId = sidebarStore.state.keyboardShortcuts[shortcut];

registry/sidebar/sidebar.tsx

Lines changed: 42 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import * as React from "react";
44
import { useStore } from "@tanstack/react-store";
5-
import { PanelLeftIcon } from "lucide-react";
65
import { cn } from "@/lib/utils";
76
import { Button } from "@/components/ui/button";
87
import {
@@ -24,36 +23,39 @@ import {
2423
PopoverTrigger,
2524
} from "@/components/ui/popover";
2625
import { sidebarStore, sidebarActions } from "./sidebar-store";
27-
import { IconChevronRight } from "@tabler/icons-react";
26+
import { IconChevronRight, IconLayoutSidebar } from "@tabler/icons-react";
2827

2928
const SIDEBAR_WIDTH = "16rem";
3029
const SIDEBAR_WIDTH_COLLAPSED = "3.5rem";
3130

3231
// Context for sidebar ID
33-
const SidebarContext = React.createContext<string | null>(null);
34-
35-
// Hook to get sidebar ID from context
36-
function useSidebarId() {
37-
const id = React.useContext(SidebarContext);
38-
if (!id) {
39-
throw new Error("Sidebar components must be used within a Sidebar component");
40-
}
41-
return id;
32+
interface SidebarContextProps {
33+
id: string;
34+
isCollapsed?: boolean;
4235
}
36+
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
4337

4438
// Hook to use sidebar
4539
export function useSidebar(id?: string) {
46-
const contextId = React.useContext(SidebarContext);
47-
const sidebarId = id ?? contextId;
40+
const context = React.useContext(SidebarContext);
41+
const sidebarId = id ?? context?.id;
42+
const isCollapsed = context?.isCollapsed;
4843

4944
if (!sidebarId) {
5045
throw new Error("useSidebar must be called with an id or within a Sidebar component");
5146
}
5247

5348
const sidebar = useStore(sidebarStore, (state) => state.sidebars[sidebarId]);
5449

50+
const effectiveSidebar = React.useMemo(() => {
51+
if (isCollapsed && sidebar) {
52+
return { ...sidebar, open: false, openMobile: false };
53+
}
54+
return sidebar;
55+
}, [sidebar, isCollapsed]);
56+
5557
return {
56-
sidebar,
58+
sidebar: effectiveSidebar,
5759
toggle: (isMobile = false) => sidebarActions.toggleSidebar(sidebarId, isMobile),
5860
setOpen: (open: boolean, isMobile = false) =>
5961
sidebarActions.setOpen(sidebarId, open, isMobile),
@@ -72,6 +74,8 @@ interface SidebarProps extends React.HTMLAttributes<HTMLDivElement> {
7274
width?: string;
7375
collapsedWidth?: string;
7476
keyboardShortcut?: string;
77+
rootClassName?: string;
78+
isCollapsed?: boolean;
7579
}
7680

7781
export function Sidebar({
@@ -84,6 +88,8 @@ export function Sidebar({
8488
collapsedWidth = SIDEBAR_WIDTH_COLLAPSED,
8589
keyboardShortcut,
8690
className,
91+
rootClassName,
92+
isCollapsed,
8793
children,
8894
...props
8995
}: SidebarProps) {
@@ -165,13 +171,13 @@ export function Sidebar({
165171
}, []);
166172

167173
// Use sidebar state if available, otherwise defaultOpen
168-
const isOpen = sidebar ? (isMobile ? sidebar.openMobile : sidebar.open) : defaultOpen;
174+
const isOpen = isCollapsed ? false : (sidebar ? (isMobile ? sidebar.openMobile : sidebar.open) : defaultOpen);
169175
const currentWidth = isOpen ? width : collapsedWidth;
170176

171177
// Mobile: show Sheet
172178
if (isMobile) {
173179
return (
174-
<SidebarContext.Provider value={id}>
180+
<SidebarContext.Provider value={{ id, isCollapsed }}>
175181
<Sheet
176182
open={sidebar ? sidebar.openMobile : false}
177183
onOpenChange={(open) => sidebarActions.setOpen(id, open, true)}
@@ -190,7 +196,7 @@ export function Sidebar({
190196

191197
const baseStyles = "sticky top-0 h-full overflow-hidden transition-all";
192198
const variantRootStyles = {
193-
default: "h-full",
199+
default: "",
194200
floating: "m-3",
195201
};
196202
const variantStyles = {
@@ -202,21 +208,21 @@ export function Sidebar({
202208
// This prevents hydration mismatch because server doesn't know localStorage state
203209
if (!isClient) {
204210
return (
205-
<div className={cn(variantRootStyles[variant])}>
211+
<div className={cn(variantRootStyles[variant], rootClassName)}>
206212
<aside
207213
data-sidebar-id={id}
208214
data-variant={variant}
209215
data-side={side}
210216
style={{
211-
width: `var(--sidebar-${id}-width, ${defaultOpen ? width : collapsedWidth})`,
212-
minWidth: `var(--sidebar-${id}-width, ${defaultOpen ? width : collapsedWidth})`,
213-
maxWidth: `var(--sidebar-${id}-width, ${defaultOpen ? width : collapsedWidth})`
217+
width: `var(--sidebar-${id}-width, ${isCollapsed ? collapsedWidth : (defaultOpen ? width : collapsedWidth)})`,
218+
minWidth: `var(--sidebar-${id}-width, ${isCollapsed ? collapsedWidth : (defaultOpen ? width : collapsedWidth)})`,
219+
maxWidth: `var(--sidebar-${id}-width, ${isCollapsed ? collapsedWidth : (defaultOpen ? width : collapsedWidth)})`
214220
}}
215221
className={cn(baseStyles, variantStyles[variant], className, "")}
216222
{...props}
217223
>
218224
{/* Skeleton - no content on server */}
219-
<div className="flex h-full flex-col overflow-hidden" style={{ width: `var(--sidebar-${id}-width, ${defaultOpen ? width : collapsedWidth})` }} >
225+
<div className="flex h-full flex-col overflow-hidden" style={{ width: `var(--sidebar-${id}-width, ${isCollapsed ? collapsedWidth : (defaultOpen ? width : collapsedWidth)})` }} >
220226

221227
</div>
222228
</aside>
@@ -226,9 +232,9 @@ export function Sidebar({
226232

227233
// Client: render full sidebar with content
228234
return (
229-
<SidebarContext.Provider value={id}>
235+
<SidebarContext.Provider value={{ id, isCollapsed }}>
230236
<TooltipProvider delayDuration={0}>
231-
<div className={cn(variantRootStyles[variant])}>
237+
<div className={cn(variantRootStyles[variant], rootClassName)}>
232238
<aside
233239
data-sidebar-id={id}
234240
data-state={isOpen ? "expanded" : "collapsed"}
@@ -546,10 +552,19 @@ export function SidebarSubmenuItem({
546552

547553
// Sidebar Trigger
548554
export function SidebarTrigger({
555+
sidebarId: propSidebarId,
549556
className,
550557
...props
551-
}: React.ButtonHTMLAttributes<HTMLButtonElement>) {
552-
const sidebarId = useSidebarId();
558+
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { sidebarId?: string }) {
559+
const context = React.useContext(SidebarContext);
560+
const sidebarId = propSidebarId ?? context?.id;
561+
562+
if (!sidebarId) {
563+
throw new Error(
564+
"SidebarTrigger must be used within a Sidebar component or passed a sidebarId prop",
565+
);
566+
}
567+
553568
const [isMobile, setIsMobile] = React.useState(false);
554569

555570
React.useEffect(() => {
@@ -569,7 +584,7 @@ export function SidebarTrigger({
569584
onClick={() => sidebarActions.toggleSidebar(sidebarId, isMobile)}
570585
{...props}
571586
>
572-
<PanelLeftIcon className="h-4 w-4" />
587+
<IconLayoutSidebar className="h-4 w-4" />
573588
<span className="sr-only">Toggle Sidebar</span>
574589
</Button>
575590
);

src/components/layout/sidebar.tsx

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
IconChevronRight,
2222
IconLayoutSidebar,
2323
IconStack2,
24+
IconX,
2425
} from "@tabler/icons-react";
2526
import { Link, useRouterState } from "@tanstack/react-router";
2627
import * as React from "react";
@@ -68,14 +69,29 @@ export function MainSidebar() {
6869
Doras UI
6970
</SidebarMenuButton>
7071
</Link>
71-
<SidebarMenuSub>
72-
<SidebarMenuButton
73-
icon={<IconLayoutSidebar />}
74-
className="hover:bg-muted! aspect-square"
75-
tooltip="Expand"
76-
onClick={() => sidebarActions.toggleSidebar(sidebarId, isMobile)}
77-
/>
78-
</SidebarMenuSub>
72+
{isMobile ? (
73+
<SidebarMenuSub>
74+
<SidebarMenuButton
75+
icon={<IconX />}
76+
className="hover:bg-muted! aspect-square"
77+
tooltip="Expand"
78+
onClick={() =>
79+
sidebarActions.toggleSidebar(sidebarId, isMobile)
80+
}
81+
/>
82+
</SidebarMenuSub>
83+
) : (
84+
<SidebarMenuSub>
85+
<SidebarMenuButton
86+
icon={<IconLayoutSidebar />}
87+
className="hover:bg-muted! aspect-square"
88+
tooltip="Expand"
89+
onClick={() =>
90+
sidebarActions.toggleSidebar(sidebarId, isMobile)
91+
}
92+
/>
93+
</SidebarMenuSub>
94+
)}
7995
</SidebarMenuItem>
8096
<SidebarMenuItem showWhenCollapsed>
8197
<SidebarMenuButton

0 commit comments

Comments
 (0)