diff --git a/apps/design-system/config/docs.ts b/apps/design-system/config/docs.ts index 471a9c89158..e96ec0b2e5c 100644 --- a/apps/design-system/config/docs.ts +++ b/apps/design-system/config/docs.ts @@ -349,6 +349,11 @@ export const docsConfig: DocsConfig = { href: '/docs/components/slider', items: [], }, + { + title: 'Sidebar', + href: '/docs/components/sidebar', + items: [], + }, { title: 'Sonner', href: '/docs/components/sonner', diff --git a/apps/design-system/content/docs/components/sidebar.mdx b/apps/design-system/content/docs/components/sidebar.mdx new file mode 100644 index 00000000000..f8706d0f4ae --- /dev/null +++ b/apps/design-system/content/docs/components/sidebar.mdx @@ -0,0 +1,1372 @@ +--- +title: Sidebar +description: A composable, themeable and customizable sidebar component. +component: true +--- + +
+ +
+ A sidebar that collapses to icons. +
+
+ +Sidebars are one of the most complex components to build. They are central +to any application and often contain a lot of moving parts. + +I don't like building sidebars. So I built 30+ of them. All kinds of +configurations. Then I extracted the core components into `sidebar.tsx`. + +We now have a solid foundation to build on top of. Composable. Themeable. +Customizable. + +[Browse the Blocks Library](/blocks). + +## Installation + + + + + CLI + Manual + + + + + +Run the following command to install `sidebar.tsx` + +```bash +npx shadcn@latest add sidebar +``` + +Add the following colors to your CSS file + +The command above should install the colors for you. If not, copy and paste the following in your CSS file. + +We'll go over the colors later in the [theming section](/docs/components/sidebar#theming). + +```css title="app/globals.css" +@layer base { + :root { + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; + } + + .dark { + --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 224.3 76.3% 48%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%; + } +} +``` + + + + + + + + + +Copy and paste the following code into your project. + + + +Update the import paths to match your project setup. + +Add the following colors to your CSS file + +We'll go over the colors later in the [theming section](/docs/components/sidebar#theming). + +```css title="app/globals.css" +@layer base { + :root { + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; + } + + .dark { + --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 224.3 76.3% 48%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%; + } +} +``` + +Add the Sidebar tailwind config into `tailwind.config.js` + +Add the following object in the `theme.extend.colors` section of your `tailwind.config.js` file +this config enable sidebar related style utilities like bg-sidebar + +```javascript title="tailwind.config.js" +// ... +sidebar: { + DEFAULT: 'hsl(var(--sidebar-background))', + foreground: 'hsl(var(--sidebar-foreground))', + primary: 'hsl(var(--sidebar-primary))', + 'primary-foreground': 'hsl(var(--sidebar-primary-foreground))', + accent: 'hsl(var(--sidebar-accent))', + 'accent-foreground': 'hsl(var(--sidebar-accent-foreground))', + border: 'hsl(var(--sidebar-border))', + ring: 'hsl(var(--sidebar-ring))', +}, +// ... +``` + + + + + + + +## Structure + +A `Sidebar` component is composed of the following parts: + +- `SidebarProvider` - Handles collapsible state. +- `Sidebar` - The sidebar container. +- `SidebarHeader` and `SidebarFooter` - Sticky at the top and bottom of the sidebar. +- `SidebarContent` - Scrollable content. +- `SidebarGroup` - Section within the `SidebarContent`. +- `SidebarTrigger` - Trigger for the `Sidebar`. + +Sidebar Structure +Sidebar Structure + +## Usage + +```tsx showLineNumbers title="app/layout.tsx" +import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar' +import { AppSidebar } from '@/components/app-sidebar' + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + +
+ + {children} +
+
+ ) +} +``` + +```tsx showLineNumbers title="components/app-sidebar.tsx" +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarHeader, +} from '@/components/ui/sidebar' + +export function AppSidebar() { + return ( + + + + + + + + + ) +} +``` + +## Your First Sidebar + +Let's start with the most basic sidebar. A collapsible sidebar with a menu. + + + +Add a `SidebarProvider` and `SidebarTrigger` at the root of your application. + +```tsx showLineNumbers title="app/layout.tsx" +import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar' +import { AppSidebar } from '@/components/app-sidebar' + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + +
+ + {children} +
+
+ ) +} +``` + +Create a new sidebar component at `components/app-sidebar.tsx`. + +```tsx showLineNumbers title="components/app-sidebar.tsx" +import { Sidebar, SidebarContent } from '@/components/ui/sidebar' + +export function AppSidebar() { + return ( + + + + ) +} +``` + +Now, let's add a `SidebarMenu` to the sidebar. + +We'll use the `SidebarMenu` component in a `SidebarGroup`. + +```tsx showLineNumbers title="components/app-sidebar.tsx" +import { Calendar, Home, Inbox, Search, Settings } from 'lucide-react' + +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from '@/components/ui/sidebar' + +// Menu items. +const items = [ + { + title: 'Home', + url: '#', + icon: Home, + }, + { + title: 'Inbox', + url: '#', + icon: Inbox, + }, + { + title: 'Calendar', + url: '#', + icon: Calendar, + }, + { + title: 'Search', + url: '#', + icon: Search, + }, + { + title: 'Settings', + url: '#', + icon: Settings, + }, +] + +export function AppSidebar() { + return ( + + + + Application + + + {items.map((item) => ( + + + + + {item.title} + + + + ))} + + + + + + ) +} +``` + +You've created your first sidebar. + +
+ +
Your first sidebar.
+
+ +
+ +## Components + +The components in `sidebar.tsx` are built to be composable i.e you build your sidebar by putting the provided components together. They also compose well with other shadcn/ui components such as `DropdownMenu`, `Collapsible` or `Dialog` etc. + +**If you need to change the code in `sidebar.tsx`, you are encouraged to do so. The code is yours. Use `sidebar.tsx` as a starting point and build your own.** + +In the next sections, we'll go over each component and how to use them. + +## SidebarProvider + +The `SidebarProvider` component is used to provide the sidebar context to the `Sidebar` component. You should always wrap your application in a `SidebarProvider` component. + +### Props + +| Name | Type | Description | +| -------------- | ------------------------- | -------------------------------------------- | +| `defaultOpen` | `boolean` | Default open state of the sidebar. | +| `open` | `boolean` | Open state of the sidebar (controlled). | +| `onOpenChange` | `(open: boolean) => void` | Sets open state of the sidebar (controlled). | + +### Width + +If you have a single sidebar in your application, you can use the `SIDEBAR_WIDTH` and `SIDEBAR_WIDTH_MOBILE` variables in `sidebar.tsx` to set the width of the sidebar. + +```tsx showLineNumbers title="components/ui/sidebar.tsx" +const SIDEBAR_WIDTH = '16rem' +const SIDEBAR_WIDTH_MOBILE = '18rem' +``` + +For multiple sidebars in your application, you can use the `style` prop to set the width of the sidebar. + +To set the width of the sidebar, you can use the `--sidebar-width` and `--sidebar-width-mobile` CSS variables in the `style` prop. + +```tsx showLineNumbers title="components/ui/sidebar.tsx" + + + +``` + +This will handle the width of the sidebar but also the layout spacing. + +### Keyboard Shortcut + +The `SIDEBAR_KEYBOARD_SHORTCUT` variable is used to set the keyboard shortcut used to open and close the sidebar. + +To trigger the sidebar, you use the `cmd+b` keyboard shortcut on Mac and `ctrl+b` on Windows. + +You can change the keyboard shortcut by updating the `SIDEBAR_KEYBOARD_SHORTCUT` variable. + +```tsx showLineNumbers title="components/ui/sidebar.tsx" +const SIDEBAR_KEYBOARD_SHORTCUT = 'b' +``` + +### Persisted State + +The `SidebarProvider` supports persisting the sidebar state across page reloads and server-side rendering. It uses cookies to store the current state of the sidebar. When the sidebar state changes, a default cookie named `sidebar_state` is set with the current open/closed state. This cookie is then read on subsequent page loads to restore the sidebar state. + +To persist sidebar state in Next.js, set up your `SidebarProvider` in `app/layout.tsx` like this: + +```tsx showLineNumbers title="app/layout.tsx" +import { cookies } from 'next/headers' + +import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar' +import { AppSidebar } from '@/components/app-sidebar' + +export async function Layout({ children }: { children: React.ReactNode }) { + const cookieStore = await cookies() + const defaultOpen = cookieStore.get('sidebar_state')?.value === 'true' + + return ( + + +
+ + {children} +
+
+ ) +} +``` + +You can change the name of the cookie by updating the `SIDEBAR_COOKIE_NAME` variable in `sidebar.tsx`. + +```tsx showLineNumbers title="components/ui/sidebar.tsx" +const SIDEBAR_COOKIE_NAME = 'sidebar_state' +``` + +## Sidebar + +The main `Sidebar` component used to render a collapsible sidebar. + +```tsx showLineNumbers +import { Sidebar } from '@/components/ui/sidebar' + +export function AppSidebar() { + return +} +``` + +### Props + +| Property | Type | Description | +| ------------- | --------------------------------- | --------------------------------- | +| `side` | `left` or `right` | The side of the sidebar. | +| `variant` | `sidebar`, `floating`, or `inset` | The variant of the sidebar. | +| `collapsible` | `offcanvas`, `icon`, or `none` | Collapsible state of the sidebar. | + +### side + +Use the `side` prop to change the side of the sidebar. + +Available options are `left` and `right`. + +```tsx showLineNumbers +import { Sidebar } from '@/components/ui/sidebar' + +export function AppSidebar() { + return +} +``` + +### variant + +Use the `variant` prop to change the variant of the sidebar. + +Available options are `sidebar`, `floating` and `inset`. + +```tsx showLineNumbers +import { Sidebar } from '@/components/ui/sidebar' + +export function AppSidebar() { + return +} +``` + + + **Note:** If you use the `inset` variant, remember to wrap your main content in a `SidebarInset` + component. + + +```tsx showLineNumbers + + + +
{children}
+
+
+``` + +### collapsible + +Use the `collapsible` prop to make the sidebar collapsible. + +Available options are `offcanvas`, `icon` and `none`. + +```tsx showLineNumbers +import { Sidebar } from '@/components/ui/sidebar' + +export function AppSidebar() { + return +} +``` + +| Prop | Description | +| ----------- | ------------------------------------------------------------ | +| `offcanvas` | A collapsible sidebar that slides in from the left or right. | +| `icon` | A sidebar that collapses to icons. | +| `none` | A non-collapsible sidebar. | + +## useSidebar + +The `useSidebar` hook is used to control the sidebar. + +```tsx showLineNumbers +import { useSidebar } from '@/components/ui/sidebar' + +export function AppSidebar() { + const { state, open, setOpen, openMobile, setOpenMobile, isMobile, toggleSidebar } = useSidebar() +} +``` + +| Property | Type | Description | +| --------------- | ------------------------- | --------------------------------------------- | +| `state` | `expanded` or `collapsed` | The current state of the sidebar. | +| `open` | `boolean` | Whether the sidebar is open. | +| `setOpen` | `(open: boolean) => void` | Sets the open state of the sidebar. | +| `openMobile` | `boolean` | Whether the sidebar is open on mobile. | +| `setOpenMobile` | `(open: boolean) => void` | Sets the open state of the sidebar on mobile. | +| `isMobile` | `boolean` | Whether the sidebar is on mobile. | +| `toggleSidebar` | `() => void` | Toggles the sidebar. Desktop and mobile. | + +## SidebarHeader + +Use the `SidebarHeader` component to add a sticky header to the sidebar. + +The following example adds a `` to the `SidebarHeader`. + +
+ +
+ A sidebar header with a dropdown menu. +
+
+ +```tsx showLineNumbers title="components/app-sidebar.tsx" + + + + + + + + Select Workspace + + + + + + Acme Inc + + + Acme Corp. + + + + + + + +``` + +## SidebarFooter + +Use the `SidebarFooter` component to add a sticky footer to the sidebar. + +The following example adds a `` to the `SidebarFooter`. + +
+ +
+ A sidebar footer with a dropdown menu. +
+
+ +```tsx showLineNumbers title="components/app-sidebar.tsx" +export function AppSidebar() { + return ( + + + + + + + + + + + Username + + + + + + Account + + + Billing + + + Sign out + + + + + + + + + ) +} +``` + +## SidebarContent + +The `SidebarContent` component is used to wrap the content of the sidebar. This is where you add your `SidebarGroup` components. It is scrollable. + +```tsx showLineNumbers +import { Sidebar, SidebarContent } from '@/components/ui/sidebar' + +export function AppSidebar() { + return ( + + + + + + + ) +} +``` + +## SidebarGroup + +Use the `SidebarGroup` component to create a section within the sidebar. + +A `SidebarGroup` has a `SidebarGroupLabel`, a `SidebarGroupContent` and an optional `SidebarGroupAction`. + +
+ +
A sidebar group.
+
+ +```tsx showLineNumbers +import { Sidebar, SidebarContent, SidebarGroup } from '@/components/ui/sidebar' + +export function AppSidebar() { + return ( + + + + Application + + Add Project + + + + + + ) +} +``` + +## Collapsible SidebarGroup + +To make a `SidebarGroup` collapsible, wrap it in a `Collapsible`. + +
+ +
+ A collapsible sidebar group. +
+
+ +```tsx showLineNumbers +export function AppSidebar() { + return ( + + + + + Help + + + + + + + + + ) +} +``` + + + **Note:** We wrap the `CollapsibleTrigger` in a `SidebarGroupLabel` to render a button. + + +## SidebarGroupAction + +Use the `SidebarGroupAction` component to add an action button to the `SidebarGroup`. + +```tsx showLineNumbers {5-7} +export function AppSidebar() { + return ( + + Projects + + Add Project + + + + ) +} +``` + +
+ +
+ A sidebar group with an action button. +
+
+ +## SidebarMenu + +The `SidebarMenu` component is used for building a menu within a `SidebarGroup`. + +A `SidebarMenu` component is composed of `SidebarMenuItem`, `SidebarMenuButton`, `` and `` components. + +Sidebar Menu +Sidebar Menu + +Here's an example of a `SidebarMenu` component rendering a list of projects. + +
+ +
+ A sidebar menu with a list of projects. +
+
+ +```tsx showLineNumbers + + + + Projects + + + {projects.map((project) => ( + + + + + {project.name} + + + + ))} + + + + + +``` + +## SidebarMenuButton + +The `SidebarMenuButton` component is used to render a menu button within a `SidebarMenuItem`. + +### Link or Anchor + +By default, the `SidebarMenuButton` renders a button but you can use the `asChild` prop to render a different component such as a `Link` or an `a` tag. + +```tsx showLineNumbers + + Home + +``` + +### Icon and Label + +You can render an icon and a truncated label inside the button. Remember to wrap the label in a ``. + +```tsx showLineNumbers + + + + Home + + +``` + +### isActive + +Use the `isActive` prop to mark a menu item as active. + +```tsx showLineNumbers + + Home + +``` + +## SidebarMenuAction + +The `SidebarMenuAction` component is used to render a menu action within a `SidebarMenuItem`. + +This button works independently of the `SidebarMenuButton` i.e you can have the `` as a clickable link and the `` as a button. + +```tsx showLineNumbers + + + + + Home + + + + Add Project + + +``` + +### DropdownMenu + +Here's an example of a `SidebarMenuAction` component rendering a `DropdownMenu`. + +
+ +
+ A sidebar menu action with a dropdown menu. +
+
+ +```tsx showLineNumbers + + + + + Home + + + + + + + + + + + Edit Project + + + Delete Project + + + + +``` + +## SidebarMenuSub + +The `SidebarMenuSub` component is used to render a submenu within a `SidebarMenu`. + +Use `` and `` to render a submenu item. + +
+ +
+ A sidebar menu with a submenu. +
+
+ +```tsx showLineNumbers + + + + + + + + + + + +``` + +## Collapsible SidebarMenu + +To make a `SidebarMenu` component collapsible, wrap it and the `SidebarMenuSub` components in a `Collapsible`. + +
+ +
A collapsible sidebar menu.
+
+ +```tsx showLineNumbers + + + + + + + + + + + + + + +``` + +## SidebarMenuBadge + +The `SidebarMenuBadge` component is used to render a badge within a `SidebarMenuItem`. + +
+ +
+ A sidebar menu with a badge. +
+
+ +```tsx showLineNumbers + + + 24 + +``` + +## SidebarMenuSkeleton + +The `SidebarMenuSkeleton` component is used to render a skeleton for a `SidebarMenu`. You can use this to show a loading state when using React Server Components, SWR or react-query. + +```tsx showLineNumbers +function NavProjectsSkeleton() { + return ( + + {Array.from({ length: 5 }).map((_, index) => ( + + + + ))} + + ) +} +``` + +## SidebarSeparator + +The `SidebarSeparator` component is used to render a separator within a `Sidebar`. + +```tsx showLineNumbers + + + + + + + + + +``` + +## SidebarTrigger + +Use the `SidebarTrigger` component to render a button that toggles the sidebar. + +The `SidebarTrigger` component must be used within a `SidebarProvider`. + +```tsx showLineNumbers + + +
+ +
+
+``` + +### Custom Trigger + +To create a custom trigger, you can use the `useSidebar` hook. + +```tsx showLineNumbers +import { useSidebar } from '@/components/ui/sidebar' + +export function CustomTrigger() { + const { toggleSidebar } = useSidebar() + + return +} +``` + +## SidebarRail + +The `SidebarRail` component is used to render a rail within a `Sidebar`. This rail can be used to toggle the sidebar. + +```tsx showLineNumbers + + + + + + + + +``` + +## Data Fetching + +### React Server Components + +Here's an example of a `SidebarMenu` component rendering a list of projects using React Server Components. + +
+ +
+ A sidebar menu using React Server Components. +
+
+ +```tsx showLineNumbers {6} title="Skeleton to show loading state." +function NavProjectsSkeleton() { + return ( + + {Array.from({ length: 5 }).map((_, index) => ( + + + + ))} + + ) +} +``` + +```tsx showLineNumbers {2} title="Server component fetching data." +async function NavProjects() { + const projects = await fetchProjects() + + return ( + + {projects.map((project) => ( + + + + + {project.name} + + + + ))} + + ) +} +``` + +```tsx showLineNumbers {8-10} title="Usage with React Suspense." +function AppSidebar() { + return ( + + + + Projects + + }> + + + + + + + ) +} +``` + +### SWR and React Query + +You can use the same approach with [SWR](https://swr.vercel.app/) or [react-query](https://tanstack.com/query/latest/docs/framework/react/overview). + +```tsx showLineNumbers title="SWR" +function NavProjects() { + const { data, isLoading } = useSWR("/api/projects", fetcher) + + if (isLoading) { + return ( + + {Array.from({ length: 5 }).map((_, index) => ( + + + + ))} + + ) + } + + if (!data) { + return ... + } + + return ( + + {data.map((project) => ( + + + + + {project.name} + + + + ))} + + ) +} +``` + +```tsx showLineNumbers title="React Query" +function NavProjects() { + const { data, isLoading } = useQuery() + + if (isLoading) { + return ( + + {Array.from({ length: 5 }).map((_, index) => ( + + + + ))} + + ) + } + + if (!data) { + return ... + } + + return ( + + {data.map((project) => ( + + + + + {project.name} + + + + ))} + + ) +} +``` + +## Controlled Sidebar + +Use the `open` and `onOpenChange` props to control the sidebar. + +
+ +
A controlled sidebar.
+
+ +```tsx showLineNumbers +export function AppSidebar() { + const [open, setOpen] = React.useState(false) + + return ( + + + + ) +} +``` + +## Theming + +We use the following CSS variables to theme the sidebar. + +```css +@layer base { + :root { + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; + } + + .dark { + --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 0 0% 98%; + --sidebar-primary-foreground: 240 5.9% 10%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%; + } +} +``` + +**We intentionally use different variables for the sidebar and the rest of the application** to make it easy to have a sidebar that is styled differently from the rest of the application. Think a sidebar with a darker shade from the main application. + +## Styling + +Here are some tips for styling the sidebar based on different states. + +- **Styling an element based on the sidebar collapsible state.** The following will hide the `SidebarGroup` when the sidebar is in `icon` mode. + +```tsx + + + + + +``` + +- **Styling a menu action based on the menu button active state.** The following will force the menu action to be visible when the menu button is active. + +```tsx + + + + +``` + +You can find more tips on using states for styling in this [Twitter thread](https://x.com/shadcn/status/1842329158879420864). + +## Changelog + +### 2024-10-30 Cookie handling in setOpen + +- [#5593](https://github.com/shadcn-ui/ui/pull/5593) - Improved setOpen callback logic in ``. + +Update the `setOpen` callback in `` as follows: + +```tsx showLineNumbers +const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === 'function' ? value(open) : value + if (setOpenProp) { + setOpenProp(openState) + } else { + _setOpen(openState) + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + }, + [setOpenProp, open] +) +``` + +### 2024-10-21 Fixed `text-sidebar-foreground` + +- [#5491](https://github.com/shadcn-ui/ui/pull/5491) - Moved `text-sidebar-foreground` from `` to `` component. + +### 2024-10-20 Typo in `useSidebar` hook. + +Fixed typo in `useSidebar` hook. + +```diff showLineNumbers title="sidebar.tsx" +- throw new Error("useSidebar must be used within a Sidebar.") ++ throw new Error("useSidebar must be used within a SidebarProvider.") +``` diff --git a/packages/ui/index.tsx b/packages/ui/index.tsx index 166fe56b8d7..37bf296a2ae 100644 --- a/packages/ui/index.tsx +++ b/packages/ui/index.tsx @@ -250,6 +250,7 @@ export * from './src/components/radio-group-card' export * from './src/components/TreeView' export * from './src/components/shadcn/ui/chart' +export * from './src/components/shadcn/ui/sidebar' // links diff --git a/packages/ui/src/components/shadcn/hooks/useMobile.ts b/packages/ui/src/components/shadcn/hooks/useMobile.ts new file mode 100644 index 00000000000..4331d5c5620 --- /dev/null +++ b/packages/ui/src/components/shadcn/hooks/useMobile.ts @@ -0,0 +1,19 @@ +import * as React from 'react' + +const MOBILE_BREAKPOINT = 768 + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState(undefined) + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + } + mql.addEventListener('change', onChange) + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + return () => mql.removeEventListener('change', onChange) + }, []) + + return !!isMobile +} diff --git a/packages/ui/src/components/shadcn/ui/sidebar.tsx b/packages/ui/src/components/shadcn/ui/sidebar.tsx new file mode 100644 index 00000000000..77aea8b0612 --- /dev/null +++ b/packages/ui/src/components/shadcn/ui/sidebar.tsx @@ -0,0 +1,740 @@ +'use client' + +import * as React from 'react' +import { Slot } from '@radix-ui/react-slot' +import { VariantProps, cva } from 'class-variance-authority' +import { PanelLeft } from 'lucide-react' + +import { useIsMobile } from '../hooks/useMobile' +import { cn } from './../../../lib/utils' +import { Button } from './button' +import { Input } from './input' +import { Separator } from './separator' +import { Sheet, SheetContent } from './sheet' +import { Skeleton } from './skeleton' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip' + +const SIDEBAR_COOKIE_NAME = 'sidebar:state' +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_WIDTH = '13rem' +const SIDEBAR_WIDTH_MOBILE = '18rem' +const SIDEBAR_WIDTH_ICON = '3rem' +const SIDEBAR_KEYBOARD_SHORTCUT = '[' + +type SidebarContext = { + state: 'expanded' | 'collapsed' + open: boolean + setOpen: (open: boolean) => void + openMobile: boolean + setOpenMobile: (open: boolean) => void + isMobile: boolean + toggleSidebar: () => void +} + +const SidebarContext = React.createContext(null) + +function useSidebar() { + const context = React.useContext(SidebarContext) + if (!context) { + throw new Error('useSidebar must be used within a SidebarProvider.') + } + + return context +} + +const SidebarProvider = React.forwardRef< + HTMLDivElement, + React.ComponentProps<'div'> & { + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void + } +>( + ( + { + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props + }, + ref + ) => { + const isMobile = useIsMobile() + const [openMobile, setOpenMobile] = React.useState(false) + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen) + const open = openProp ?? _open + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === 'function' ? value(open) : value + if (setOpenProp) { + setOpenProp(openState) + } else { + _setOpen(openState) + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + }, + [setOpenProp, open] + ) + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) + }, [isMobile, setOpen, setOpenMobile]) + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { + event.preventDefault() + toggleSidebar() + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [toggleSidebar]) + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? 'expanded' : 'collapsed' + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ) + + return ( + + +
+ {children} +
+
+
+ ) + } +) +SidebarProvider.displayName = 'SidebarProvider' + +const Sidebar = React.forwardRef< + HTMLDivElement, + React.ComponentProps<'div'> & { + side?: 'left' | 'right' + variant?: 'sidebar' | 'floating' | 'inset' + collapsible?: 'offcanvas' | 'icon' | 'none' + } +>( + ( + { + side = 'left', + variant = 'sidebar', + collapsible = 'offcanvas', + className, + children, + ...props + }, + ref + ) => { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + + if (collapsible === 'none') { + return ( +
+ {children} +
+ ) + } + + if (isMobile) { + return ( + + +
{children}
+
+
+ ) + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ) + } +) +Sidebar.displayName = 'Sidebar' + +const SidebarTrigger = React.forwardRef< + React.ElementRef, + React.ComponentProps +>(({ className, onClick, ...props }, ref) => { + const { toggleSidebar } = useSidebar() + + return ( + + ) +}) +SidebarTrigger.displayName = 'SidebarTrigger' + +const SidebarRail = React.forwardRef>( + ({ className, ...props }, ref) => { + const { toggleSidebar } = useSidebar() + + return ( +