diff --git a/src/frontend/packages/ui/package.json b/src/frontend/packages/ui/package.json index 85e2ed9e..2df7a6af 100644 --- a/src/frontend/packages/ui/package.json +++ b/src/frontend/packages/ui/package.json @@ -10,6 +10,8 @@ }, "dependencies": { "@radix-ui/react-avatar": "^1.1.2", + "@radix-ui/react-collapsible": "^1.1.2", + "@radix-ui/react-dialog": "^1.1.5", "@radix-ui/react-label": "^2.1.1", "@radix-ui/react-popover": "^1.1.5", "@radix-ui/react-separator": "^1.1.1", @@ -17,6 +19,7 @@ "@radix-ui/react-toast": "^1.2.4", "@radix-ui/react-toggle": "^1.1.1", "@radix-ui/react-toggle-group": "^1.1.1", + "@radix-ui/react-tooltip": "^1.1.7", "@tanstack/react-table": "^8.20.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/src/frontend/packages/ui/src/components/Collapsible/collapsible.stories.tsx b/src/frontend/packages/ui/src/components/Collapsible/collapsible.stories.tsx new file mode 100644 index 00000000..957664cb --- /dev/null +++ b/src/frontend/packages/ui/src/components/Collapsible/collapsible.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './collapsible'; +import { Button } from '../Button'; +import { useState } from 'react'; + +const meta: Meta = { + title: 'Widget/Collapsible', + component: Collapsible, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => { + const [isOpen, setIsOpen] = useState(false); + return ( + +
+

Title

+ + + +
+
Contents1
+ +
Contents2
+
Contents3
+
+
+ ); + }, +}; diff --git a/src/frontend/packages/ui/src/components/Collapsible/collapsible.tsx b/src/frontend/packages/ui/src/components/Collapsible/collapsible.tsx new file mode 100644 index 00000000..86ab87d8 --- /dev/null +++ b/src/frontend/packages/ui/src/components/Collapsible/collapsible.tsx @@ -0,0 +1,11 @@ +'use client'; + +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/src/frontend/packages/ui/src/components/Collapsible/index.ts b/src/frontend/packages/ui/src/components/Collapsible/index.ts new file mode 100644 index 00000000..566d9187 --- /dev/null +++ b/src/frontend/packages/ui/src/components/Collapsible/index.ts @@ -0,0 +1 @@ +export { Collapsible, CollapsibleTrigger, CollapsibleContent } from './collapsible'; diff --git a/src/frontend/packages/ui/src/components/Sheet/index.ts b/src/frontend/packages/ui/src/components/Sheet/index.ts new file mode 100644 index 00000000..53eeba48 --- /dev/null +++ b/src/frontend/packages/ui/src/components/Sheet/index.ts @@ -0,0 +1 @@ +export { Sheet, SheetPortal, SheetOverlay, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription } from './sheet'; diff --git a/src/frontend/packages/ui/src/components/Sheet/sheet.stories.tsx b/src/frontend/packages/ui/src/components/Sheet/sheet.stories.tsx new file mode 100644 index 00000000..ba27622e --- /dev/null +++ b/src/frontend/packages/ui/src/components/Sheet/sheet.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from './sheet'; +import { Button } from '../Button'; + +const meta: Meta = { + title: 'Widget/Sheet', + component: Sheet, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + + + + + + Sheet Title + Sheet description + + content + + CLOSE + + + + ), +}; diff --git a/src/frontend/packages/ui/src/components/Sheet/sheet.tsx b/src/frontend/packages/ui/src/components/Sheet/sheet.tsx new file mode 100644 index 00000000..c9b3c4a0 --- /dev/null +++ b/src/frontend/packages/ui/src/components/Sheet/sheet.tsx @@ -0,0 +1,105 @@ +'use client'; + +import * as React from 'react'; +import * as SheetPrimitive from '@radix-ui/react-dialog'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { X } from 'lucide-react'; + +import { cn } from '@workspace/ui/lib/utils'; + +const Sheet = SheetPrimitive.Root; + +const SheetTrigger = SheetPrimitive.Trigger; + +const SheetClose = SheetPrimitive.Close; + +const SheetPortal = SheetPrimitive.Portal; + +const SheetOverlay = React.forwardRef, React.ComponentPropsWithoutRef>( + ({ className, ...props }, ref) => ( + + ), +); +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; + +const sheetVariants = cva( + 'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out', + { + variants: { + side: { + top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top', + bottom: 'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom', + left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm', + right: 'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm', + }, + }, + defaultVariants: { + side: 'right', + }, + }, +); + +interface SheetContentProps extends React.ComponentPropsWithoutRef, VariantProps {} + +const SheetContent = React.forwardRef, SheetContentProps>(({ side = 'right', className, children, ...props }, ref) => ( + + + + + + Close + + {children} + + +)); +SheetContent.displayName = SheetPrimitive.Content.displayName; + +const SheetHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +SheetHeader.displayName = 'SheetHeader'; + +const SheetFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +SheetFooter.displayName = 'SheetFooter'; + +const SheetTitle = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, ...props }, ref) => ( + +)); +SheetTitle.displayName = SheetPrimitive.Title.displayName; + +const SheetDescription = React.forwardRef, React.ComponentPropsWithoutRef>( + ({ className, ...props }, ref) => ( + + ), +); +SheetDescription.displayName = SheetPrimitive.Description.displayName; + +export { Sheet, SheetPortal, SheetOverlay, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription }; diff --git a/src/frontend/packages/ui/src/components/Sidebar/index.ts b/src/frontend/packages/ui/src/components/Sidebar/index.ts new file mode 100644 index 00000000..69b837ed --- /dev/null +++ b/src/frontend/packages/ui/src/components/Sidebar/index.ts @@ -0,0 +1,26 @@ +export { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupAction, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarInput, + SidebarInset, + SidebarMenu, + SidebarMenuAction, + SidebarMenuBadge, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSkeleton, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, + SidebarProvider, + SidebarRail, + SidebarSeparator, + SidebarTrigger, + useSidebar, +} from './sidebar'; diff --git a/src/frontend/packages/ui/src/components/Sidebar/sidebar.stories.tsx b/src/frontend/packages/ui/src/components/Sidebar/sidebar.stories.tsx new file mode 100644 index 00000000..7b18a9b2 --- /dev/null +++ b/src/frontend/packages/ui/src/components/Sidebar/sidebar.stories.tsx @@ -0,0 +1,111 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarInset, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarProvider, + SidebarTrigger, +} from './sidebar'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../Collapsible'; +import { ChevronRight } from 'lucide-react'; + +const meta: Meta = { + title: 'Widget/Sidebar', + component: Sidebar, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => { + const data = { + navMain: [ + { + title: 'Group1', + items: [ + { + title: 'one', + url: '#', + }, + { + title: 'two', + url: '#', + }, + ], + }, + { + title: 'Group2', + items: [ + { + title: 'three', + url: '#', + }, + { + title: 'four', + url: '#', + isActive: true, + }, + ], + }, + ], + }; + return ( + + + #WSName + + {data.navMain.map((item) => ( + + + + + {item.title} + + + + + + {item.items.map((item) => ( + + + {item.title} + + + ))} + + + + + + ))} + + + + + + + ); + }, +}; diff --git a/src/frontend/packages/ui/src/components/Sidebar/sidebar.tsx b/src/frontend/packages/ui/src/components/Sidebar/sidebar.tsx new file mode 100644 index 00000000..f5fe5f8d --- /dev/null +++ b/src/frontend/packages/ui/src/components/Sidebar/sidebar.tsx @@ -0,0 +1,657 @@ +'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/Sidebar/use-mobile'; +import { cn } from '@workspace/ui/lib/utils'; +import { Button } from '../Button'; +import { Input } from '../Input'; +import { Separator } from '../Separate'; +import { Sheet, SheetContent } from '../Sheet/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 = '11rem'; +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 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.ComponentProps>(({ className, onClick, ...props }, ref) => { + const { toggleSidebar } = useSidebar(); + + return ( + + ); +}); +SidebarTrigger.displayName = 'SidebarTrigger'; + +const SidebarRail = React.forwardRef>(({ className, ...props }, ref) => { + const { toggleSidebar } = useSidebar(); + + return ( +