diff --git a/bun.lockb b/bun.lockb index 1e9e72c..978d1b4 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/ui/package.json b/packages/ui/package.json index eb69616..164c00f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -8,12 +8,15 @@ "@hookform/resolvers": "^3.9.0", "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-alert-dialog": "^1.1.2", + "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-checkbox": "^1.1.2", + "@radix-ui/react-collapsible": "^1.1.1", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-select": "^2.1.2", + "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.3", "class-variance-authority": "^0.7.0", diff --git a/packages/ui/src/avatar.tsx b/packages/ui/src/avatar.tsx new file mode 100644 index 0000000..8568c56 --- /dev/null +++ b/packages/ui/src/avatar.tsx @@ -0,0 +1,48 @@ +import * as AvatarPrimitive from '@radix-ui/react-avatar' +import * as React from 'react' + +import { cn } from './lib/cn' + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/packages/ui/src/collapsible.tsx b/packages/ui/src/collapsible.tsx new file mode 100644 index 0000000..7cee61e --- /dev/null +++ b/packages/ui/src/collapsible.tsx @@ -0,0 +1,9 @@ +import * as CollapsiblePrimitive from '@radix-ui/react-collapsible' + +const Collapsible = CollapsiblePrimitive.Root + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger + +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/packages/ui/src/global.css b/packages/ui/src/global.css index 95d8cab..3836550 100644 --- a/packages/ui/src/global.css +++ b/packages/ui/src/global.css @@ -34,6 +34,15 @@ --ring: 215 20.2% 65.1%; --radius: 0.5rem; + + --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 { @@ -67,6 +76,15 @@ --ring: 216 34% 17%; --radius: 0.5rem; + + --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%; } } @@ -76,6 +94,8 @@ } body { @apply bg-background text-foreground; - font-feature-settings: "rlig" 1, "calt" 1; + font-feature-settings: + "rlig" 1, + "calt" 1; } } diff --git a/packages/ui/src/hooks/useIsMobile.tsx b/packages/ui/src/hooks/useIsMobile.tsx new file mode 100644 index 0000000..4331d5c --- /dev/null +++ b/packages/ui/src/hooks/useIsMobile.tsx @@ -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/index.ts b/packages/ui/src/index.ts index 8426db4..48c4bd8 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -19,3 +19,8 @@ export * from './popover' export * from './calendar' export * from './date-picker' export * from './alert' +export * from './separator' +export * from './skeleton' +export * from './sidebar' +export * from './collapsible' +export * from './avatar' diff --git a/packages/ui/src/separator.tsx b/packages/ui/src/separator.tsx new file mode 100644 index 0000000..842667f --- /dev/null +++ b/packages/ui/src/separator.tsx @@ -0,0 +1,29 @@ +import * as SeparatorPrimitive from '@radix-ui/react-separator' +import * as React from 'react' + +import { cn } from './lib/cn' + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = 'horizontal', decorative = true, ...props }, + ref, + ) => ( + + ), +) +Separator.displayName = SeparatorPrimitive.Root.displayName + +export { Separator } diff --git a/packages/ui/src/sidebar.tsx b/packages/ui/src/sidebar.tsx new file mode 100644 index 0000000..341e8b7 --- /dev/null +++ b/packages/ui/src/sidebar.tsx @@ -0,0 +1,762 @@ +import { Slot } from '@radix-ui/react-slot' +import { type VariantProps, cva } from 'class-variance-authority' +import { PanelLeft } from 'lucide-react' +import * as React from 'react' + +import { Button } from './button' +import { useIsMobile } from './hooks/useIsMobile' +import { Input } from './input' +import { cn } from './lib/cn' +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 = '16rem' +const SIDEBAR_WIDTH_MOBILE = '18rem' +const SIDEBAR_WIDTH_ICON = '3rem' +const SIDEBAR_KEYBOARD_SHORTCUT = 'b' + +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 Sidebar.') + } + + 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)) => { + if (setOpenProp) { + return setOpenProp?.( + typeof value === 'function' ? value(open) : value, + ) + } + + _setOpen(value) + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${open}; 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]) + + // 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, 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< + HTMLButtonElement, + React.ComponentProps<'button'> +>(({ className, ...props }, ref) => { + const { toggleSidebar } = useSidebar() + + return ( +