417 lines
11 KiB
TypeScript
417 lines
11 KiB
TypeScript
import { Slot } from '@radix-ui/react-slot'
|
|
import { PanelLeftIcon } from 'lucide-react'
|
|
import * as React from 'react'
|
|
|
|
import { cn } from 'shared/lib/utils'
|
|
import { Button } from 'shared/ui/button'
|
|
import { Input } from 'shared/ui/input'
|
|
import { Separator } from 'shared/ui/separator'
|
|
|
|
const SIDEBAR_COOKIE_NAME = 'sidebar_state'
|
|
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
|
const SIDEBAR_WIDTH = '16rem'
|
|
const SIDEBAR_WIDTH_MOBILE = '18rem'
|
|
const SIDEBAR_WIDTH_ICON = '3rem'
|
|
const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
|
|
|
|
type SidebarContextProps = {
|
|
state: 'expanded' | 'collapsed'
|
|
open: boolean
|
|
setOpen: (open: boolean) => void
|
|
openMobile: boolean
|
|
setOpenMobile: (open: boolean) => void
|
|
isMobile: boolean
|
|
toggleSidebar: () => void
|
|
}
|
|
|
|
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
|
|
|
function useSidebar() {
|
|
const context = React.useContext(SidebarContext)
|
|
if (!context) {
|
|
throw new Error('useSidebar must be used within a SidebarProvider.')
|
|
}
|
|
|
|
return context
|
|
}
|
|
|
|
// Simple mobile hook implementation
|
|
function useIsMobile() {
|
|
const [isMobile, setIsMobile] = React.useState(false)
|
|
|
|
React.useEffect(() => {
|
|
const checkMobile = () => {
|
|
setIsMobile(window.innerWidth < 768)
|
|
}
|
|
|
|
checkMobile()
|
|
window.addEventListener('resize', checkMobile)
|
|
return () => window.removeEventListener('resize', checkMobile)
|
|
}, [])
|
|
|
|
return isMobile
|
|
}
|
|
|
|
function SidebarProvider({
|
|
defaultOpen = true,
|
|
open: openProp,
|
|
onOpenChange: setOpenProp,
|
|
className,
|
|
style,
|
|
children,
|
|
...props
|
|
}: React.ComponentProps<'div'> & {
|
|
defaultOpen?: boolean
|
|
open?: boolean
|
|
onOpenChange?: (open: boolean) => void
|
|
}) {
|
|
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<SidebarContextProps>(
|
|
() => ({
|
|
state,
|
|
open,
|
|
setOpen,
|
|
isMobile,
|
|
openMobile,
|
|
setOpenMobile,
|
|
toggleSidebar,
|
|
}),
|
|
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
|
)
|
|
|
|
return (
|
|
<SidebarContext.Provider value={contextValue}>
|
|
<div
|
|
data-slot="sidebar-wrapper"
|
|
style={
|
|
{
|
|
'--sidebar-width': SIDEBAR_WIDTH,
|
|
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
|
|
...style,
|
|
} as React.CSSProperties
|
|
}
|
|
className={cn(
|
|
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
|
|
className
|
|
)}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</div>
|
|
</SidebarContext.Provider>
|
|
)
|
|
}
|
|
|
|
function Sidebar({
|
|
side = 'left',
|
|
variant = 'sidebar',
|
|
collapsible = 'offcanvas',
|
|
className,
|
|
children,
|
|
...props
|
|
}: React.ComponentProps<'div'> & {
|
|
side?: 'left' | 'right'
|
|
variant?: 'sidebar' | 'floating' | 'inset'
|
|
collapsible?: 'offcanvas' | 'icon' | 'none'
|
|
}) {
|
|
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
|
|
|
if (collapsible === 'none') {
|
|
return (
|
|
<div
|
|
data-slot="sidebar"
|
|
className={cn(
|
|
'bg-sidebar text-sidebar-foreground flex h-full w-[--sidebar-width] flex-col',
|
|
className
|
|
)}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (isMobile) {
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-50 bg-black/50"
|
|
onClick={() => setOpenMobile(false)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
'fixed inset-y-0 left-0 w-[--sidebar-width] bg-background p-4 text-foreground shadow-lg transition-transform duration-300',
|
|
openMobile ? 'translate-x-0' : '-translate-x-full'
|
|
)}
|
|
style={
|
|
{
|
|
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
|
|
} as React.CSSProperties
|
|
}
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
<div className="flex h-full w-full flex-col">{children}</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="text-sidebar-foreground group peer hidden md:block"
|
|
data-state={state}
|
|
data-collapsible={state === 'collapsed' ? collapsible : ''}
|
|
data-variant={variant}
|
|
data-side={side}
|
|
data-slot="sidebar"
|
|
>
|
|
{/* This is what handles the sidebar gap on desktop */}
|
|
<div
|
|
data-slot="sidebar-gap"
|
|
className={cn(
|
|
'relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear',
|
|
'group-data-[collapsible=offcanvas]:w-0',
|
|
'group-data-[side=right]:rotate-180',
|
|
variant === 'floating' || variant === 'inset'
|
|
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+1rem)]'
|
|
: 'group-data-[collapsible=icon]:w-[--sidebar-width-icon]'
|
|
)}
|
|
/>
|
|
<div
|
|
data-slot="sidebar-container"
|
|
className={cn(
|
|
'fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex',
|
|
side === 'left'
|
|
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
|
|
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
|
|
// Adjust the padding for floating and inset variants.
|
|
variant === 'floating' || variant === 'inset'
|
|
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+1rem+2px)]'
|
|
: 'group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l',
|
|
className
|
|
)}
|
|
{...props}
|
|
>
|
|
<div
|
|
data-sidebar="sidebar"
|
|
data-slot="sidebar-inner"
|
|
className="flex h-full w-full flex-col border-r bg-background group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
|
>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SidebarTrigger({
|
|
className,
|
|
onClick,
|
|
...props
|
|
}: React.ComponentProps<typeof Button>) {
|
|
const { toggleSidebar } = useSidebar()
|
|
|
|
return (
|
|
<Button
|
|
data-sidebar="trigger"
|
|
data-slot="sidebar-trigger"
|
|
variant="ghost"
|
|
size="icon"
|
|
className={cn('size-7', className)}
|
|
onClick={event => {
|
|
onClick?.(event)
|
|
toggleSidebar()
|
|
}}
|
|
{...props}
|
|
>
|
|
<PanelLeftIcon />
|
|
<span className="sr-only">Toggle Sidebar</span>
|
|
</Button>
|
|
)
|
|
}
|
|
|
|
function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
|
|
return (
|
|
<main
|
|
data-slot="sidebar-inset"
|
|
className={cn(
|
|
'relative flex w-full flex-1 flex-col bg-background',
|
|
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm',
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function SidebarInput({
|
|
className,
|
|
...props
|
|
}: React.ComponentProps<typeof Input>) {
|
|
return (
|
|
<Input
|
|
data-slot="sidebar-input"
|
|
data-sidebar="input"
|
|
className={cn('h-8 w-full bg-background shadow-none', className)}
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
|
return (
|
|
<div
|
|
data-slot="sidebar-header"
|
|
data-sidebar="header"
|
|
className={cn('flex flex-col gap-2 p-2', className)}
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
|
return (
|
|
<div
|
|
data-slot="sidebar-footer"
|
|
data-sidebar="footer"
|
|
className={cn('flex flex-col gap-2 p-2', className)}
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function SidebarSeparator({
|
|
className,
|
|
...props
|
|
}: React.ComponentProps<typeof Separator>) {
|
|
return (
|
|
<Separator
|
|
data-slot="sidebar-separator"
|
|
data-sidebar="separator"
|
|
className={cn('mx-2 w-auto bg-border', className)}
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
|
|
return (
|
|
<div
|
|
data-slot="sidebar-content"
|
|
data-sidebar="content"
|
|
className={cn(
|
|
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
|
return (
|
|
<div
|
|
data-slot="sidebar-group"
|
|
data-sidebar="group"
|
|
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function SidebarGroupLabel({
|
|
className,
|
|
asChild = false,
|
|
...props
|
|
}: React.ComponentProps<'div'> & { asChild?: boolean }) {
|
|
const Comp = asChild ? Slot : 'div'
|
|
|
|
return (
|
|
<Comp
|
|
data-slot="sidebar-group-label"
|
|
data-sidebar="group-label"
|
|
className={cn(
|
|
'outline-hidden flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-foreground/70 ring-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
|
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function SidebarGroupContent({
|
|
className,
|
|
...props
|
|
}: React.ComponentProps<'div'>) {
|
|
return (
|
|
<div
|
|
data-slot="sidebar-group-content"
|
|
data-sidebar="group-content"
|
|
className={cn('w-full text-sm', className)}
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
|
|
export {
|
|
Sidebar,
|
|
SidebarContent,
|
|
SidebarFooter,
|
|
SidebarGroup,
|
|
SidebarGroupContent,
|
|
SidebarGroupLabel,
|
|
SidebarHeader,
|
|
SidebarInput,
|
|
SidebarInset,
|
|
SidebarProvider,
|
|
SidebarSeparator,
|
|
SidebarTrigger,
|
|
useSidebar,
|
|
}
|