From 311ae14557b4c11b6640053e0313edf2cd075e1d Mon Sep 17 00:00:00 2001 From: myusername Date: Thu, 14 May 2026 15:53:33 +0530 Subject: [PATCH] feat: Drawer and SidebarNav component created. --- src/index.css | 10 + src/layouts/MainLayout/BaseLayout.tsx | 4 +- .../composites/Drawer/Drawer.stories.tsx | 84 +++++ src/shared/composites/Drawer/Drawer.tsx | 302 ++++++++++++++++++ src/shared/composites/Drawer/index.ts | 3 + .../SidebarNav/SidebarNav.stories.tsx | 87 +++++ .../composites/SidebarNav/SidebarNav.tsx | 144 +++++++++ src/shared/composites/SidebarNav/index.ts | 3 + .../SidebarNavItem/SidebarNavItem.tsx | 27 +- src/shared/primitives/Badge/Badge.tsx | 46 ++- src/shared/primitives/Tooltip/Tooltip.tsx | 6 +- src/views/LoginPage.tsx | 131 +++++++- 12 files changed, 818 insertions(+), 29 deletions(-) create mode 100644 src/shared/composites/Drawer/Drawer.stories.tsx create mode 100644 src/shared/composites/Drawer/Drawer.tsx create mode 100644 src/shared/composites/Drawer/index.ts create mode 100644 src/shared/composites/SidebarNav/SidebarNav.stories.tsx create mode 100644 src/shared/composites/SidebarNav/SidebarNav.tsx create mode 100644 src/shared/composites/SidebarNav/index.ts diff --git a/src/index.css b/src/index.css index 7573056..ca44d0f 100644 --- a/src/index.css +++ b/src/index.css @@ -126,3 +126,13 @@ html[data-theme-loaded='true'] * { background-position: -200% 0; } } + +@keyframes drawerFadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} diff --git a/src/layouts/MainLayout/BaseLayout.tsx b/src/layouts/MainLayout/BaseLayout.tsx index 83dfb3b..6851278 100644 --- a/src/layouts/MainLayout/BaseLayout.tsx +++ b/src/layouts/MainLayout/BaseLayout.tsx @@ -10,8 +10,8 @@ const BaseLayout: React.FC = () => {
{/* Main Content */} -
-
+
+
diff --git a/src/shared/composites/Drawer/Drawer.stories.tsx b/src/shared/composites/Drawer/Drawer.stories.tsx new file mode 100644 index 0000000..fa7a450 --- /dev/null +++ b/src/shared/composites/Drawer/Drawer.stories.tsx @@ -0,0 +1,84 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' + +import { useState } from 'react' + +import { Drawer } from '.' + +import { Button } from '@/shared/primitives/Button' + +const meta = { + title: 'Shared/Composites/Drawer', + + component: Drawer, + + tags: ['autodocs'], +} satisfies Meta + +export default meta + +type Story = StoryObj + +function DrawerDemo(props: Partial>) { + const [open, setOpen] = useState(false) + + return ( + <> + + + { + setOpen(false) + }} + title="Drawer Title" + {...props} + > +
+

Drawer content

+ +

More content

+ +

More content

+ +

More content

+ +

More content

+
+
+ + ) +} + +export const Right: Story = { + render: () => , +} + +export const Left: Story = { + render: () => , +} + +export const Bottom: Story = { + render: () => , +} + +export const WithFooter: Story = { + render: () => ( + + +
+ } + /> + ), +} + +export const NonDismissable: Story = { + render: () => , +} diff --git a/src/shared/composites/Drawer/Drawer.tsx b/src/shared/composites/Drawer/Drawer.tsx new file mode 100644 index 0000000..5f8092d --- /dev/null +++ b/src/shared/composites/Drawer/Drawer.tsx @@ -0,0 +1,302 @@ +import { useEffect, useMemo, useRef } from 'react' + +import { createPortal } from 'react-dom' + +import { XMarkIcon } from '@heroicons/react/24/outline' + +import { cva } from 'class-variance-authority' + +export interface DrawerProps { + isOpen: boolean + + onClose: () => void + + title?: string + + children: React.ReactNode + + footer?: React.ReactNode + + side?: 'left' | 'right' | 'bottom' + + size?: 'sm' | 'md' | 'lg' + + isDismissable?: boolean + + className?: string +} + +const drawerStyles = cva( + ` + fixed + z-50 + bg-bg-primary + border-border-secondary + shadow-md + transition-transform + duration-300 + flex + flex-col + `, + { + variants: { + side: { + left: ` + left-0 + top-0 + h-screen + border-r + `, + + right: ` + right-0 + top-0 + h-screen + border-l + `, + + bottom: ` + bottom-0 + left-0 + w-full + border-t + rounded-t-xl + `, + }, + + size: { + sm: '', + + md: '', + + lg: '', + }, + + open: { + true: '', + + false: '', + }, + }, + + compoundVariants: [ + // LEFT + { + side: 'left', + size: 'sm', + className: 'w-[320px]', + }, + + { + side: 'left', + size: 'md', + className: 'w-[480px]', + }, + + { + side: 'left', + size: 'lg', + className: 'w-[640px]', + }, + + // RIGHT + { + side: 'right', + size: 'sm', + className: 'w-[320px]', + }, + + { + side: 'right', + size: 'md', + className: 'w-[480px]', + }, + + { + side: 'right', + size: 'lg', + className: 'w-[640px]', + }, + + // BOTTOM + { + side: 'bottom', + size: 'sm', + className: 'h-[40vh]', + }, + + { + side: 'bottom', + size: 'md', + className: 'h-[60vh]', + }, + + { + side: 'bottom', + size: 'lg', + className: 'h-[90vh]', + }, + + // CLOSED STATES + { + side: 'left', + open: false, + className: '-translate-x-full', + }, + + { + side: 'right', + open: false, + className: 'translate-x-full', + }, + + { + side: 'bottom', + open: false, + className: 'translate-y-full', + }, + + // OPEN + { + open: true, + className: 'translate-x-0 translate-y-0', + }, + ], + + defaultVariants: { + side: 'right', + + size: 'md', + + open: false, + }, + } +) + +export function Drawer({ + isOpen, + + onClose, + + title, + + children, + + footer, + + side = 'right', + + size = 'md', + + isDismissable = true, + + className = '', +}: DrawerProps) { + const drawerRef = useRef(null) + + const isMobile = window.innerWidth < 768 + + const resolvedSide = useMemo(() => { + if (isMobile && side === 'right') { + return 'bottom' + } + + return side + }, [isMobile, side]) + + useEffect(() => { + if (!isOpen) return + + document.body.style.overflow = 'hidden' + + return () => { + document.body.style.overflow = '' + } + }, [isOpen]) + + useEffect(() => { + if (!isOpen || !isDismissable) { + return + } + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose() + } + } + + window.addEventListener('keydown', handleKeyDown) + + return () => { + window.removeEventListener('keydown', handleKeyDown) + } + }, [isOpen, isDismissable, onClose]) + + useEffect(() => { + if (isOpen && drawerRef.current) { + drawerRef.current.focus() + } + }, [isOpen]) + + if (!isOpen) { + return null + } + + return createPortal( + <> + {/* Overlay */} + + )} +
+ + {/* Content */} +
{children}
+ + {/* Footer */} + {footer && ( +
+ {footer} +
+ )} +
+ , + document.body + ) +} + +export default Drawer diff --git a/src/shared/composites/Drawer/index.ts b/src/shared/composites/Drawer/index.ts new file mode 100644 index 0000000..f81c7b2 --- /dev/null +++ b/src/shared/composites/Drawer/index.ts @@ -0,0 +1,3 @@ +export { Drawer } from './Drawer' + +export type { DrawerProps } from './Drawer' diff --git a/src/shared/composites/SidebarNav/SidebarNav.stories.tsx b/src/shared/composites/SidebarNav/SidebarNav.stories.tsx new file mode 100644 index 0000000..fbdaa38 --- /dev/null +++ b/src/shared/composites/SidebarNav/SidebarNav.stories.tsx @@ -0,0 +1,87 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' + +import { useState } from 'react' + +import { + HomeIcon, + FlagIcon, + ExclamationTriangleIcon, + UsersIcon, + ShieldCheckIcon, +} from '@heroicons/react/24/outline' + +import { SidebarNav } from '.' + +const meta = { + title: 'Shared/Composites/SidebarNav', + + component: SidebarNav, + + tags: ['autodocs'], +} satisfies Meta + +export default meta + +type Story = StoryObj + +function SidebarDemo() { + const [collapsed, setCollapsed] = useState(false) + + return ( + { + setCollapsed(!collapsed) + }} + items={[ + { + label: 'Overview', + + icon: , + + isActive: true, + }, + + { + label: 'Reports', + + icon: , + + badge: 3, + + badgeVariant: 'danger', + }, + + { + label: 'Escalated', + + icon: , + + badge: 1, + + badgeVariant: 'danger', + }, + + { + label: 'Users', + + icon: , + }, + + { + label: 'Security', + + icon: , + + badge: 'new', + + badgeVariant: 'info', + }, + ]} + /> + ) +} + +export const Default: Story = { + render: () => , +} diff --git a/src/shared/composites/SidebarNav/SidebarNav.tsx b/src/shared/composites/SidebarNav/SidebarNav.tsx new file mode 100644 index 0000000..4b54264 --- /dev/null +++ b/src/shared/composites/SidebarNav/SidebarNav.tsx @@ -0,0 +1,144 @@ +import React from 'react' + +import { Bars3Icon } from '@heroicons/react/24/outline' + +import { Tooltip } from '@/shared/primitives/Tooltip' + +import { SidebarNavItem } from '@/shared/composites/SidebarNavItem' + +import { cva } from 'class-variance-authority' +import { Badge } from '@/shared/primitives/Badge' + +export interface SidebarNavLink { + label: string + + icon: React.ReactNode + + isActive?: boolean + + badge?: number | string + + badgeVariant?: 'danger' | 'info' + + onClick?: () => void +} + +export interface SidebarNavProps { + items: SidebarNavLink[] + + collapsed?: boolean + + onToggle?: () => void + + className?: string + + onClick?: () => void +} + +const sidebarStyles = cva( + ` + flex + h-screen + flex-col + border-r + border-border-secondary + bg-bg-secondary + transition-all + duration-300 + `, + { + variants: { + collapsed: { + true: 'w-[72px]', + false: 'w-64', + }, + }, + + defaultVariants: { + collapsed: false, + }, + } +) + +export function SidebarNav({ + items, + + collapsed = false, + + onToggle, + + // className, +}: SidebarNavProps) { + const classes = ' fixed left-0 top-0 h-screen z-30' + return ( +
+ } + isActive={item.isActive} + badge={collapsed ? undefined : item.badge} + badgeVariant={item.badgeVariant} + onClick={item.onClick} + className={ + collapsed ? 'hover:bg-bg-tertiary justify-center px-0' : 'hover:bg-bg-tertiary' + } + /> + ) + + if (collapsed) { + return ( + + {navItem} + + ) + } + + return navItem + })} + + + ) +} + +export default SidebarNav diff --git a/src/shared/composites/SidebarNav/index.ts b/src/shared/composites/SidebarNav/index.ts new file mode 100644 index 0000000..7badc39 --- /dev/null +++ b/src/shared/composites/SidebarNav/index.ts @@ -0,0 +1,3 @@ +export { SidebarNav } from './SidebarNav' + +export type { SidebarNavProps, SidebarNavLink } from './SidebarNav' diff --git a/src/shared/composites/SidebarNavItem/SidebarNavItem.tsx b/src/shared/composites/SidebarNavItem/SidebarNavItem.tsx index 7023112..5c25db4 100644 --- a/src/shared/composites/SidebarNavItem/SidebarNavItem.tsx +++ b/src/shared/composites/SidebarNavItem/SidebarNavItem.tsx @@ -6,7 +6,7 @@ import { Badge } from '../../primitives/Badge' const sidebarNavItem = cva( [ 'w-full', - 'flex items-center justify-between', + 'flex items-center', 'gap-3', 'rounded-lg', @@ -27,9 +27,14 @@ const sidebarNavItem = cva( { variants: { isActive: { - true: ['bg-bg-secondary', 'text-text-primary', 'font-medium'], - - false: ['text-text-tertiary', 'hover:bg-bg-secondary', 'hover:text-text-primary'], + true: ['bg-bg-tertiary', 'text-text-primary', 'font-medium'], + + false: [ + 'bg-bg-secondary', + 'text-text-tertiary', + 'hover:bg-bg-secondary', + 'hover:text-text-primary', + ], }, }, @@ -53,23 +58,27 @@ export interface SidebarNavItemProps { badgeVariant?: 'danger' | 'info' className?: string + + onClick?: () => void } export function SidebarNavItem({ - // link, label, icon, isActive = false, badge, badgeVariant = 'info', + onClick, className, }: SidebarNavItemProps) { return ( -
{/* Left side */} @@ -85,7 +94,7 @@ export function SidebarNavItem({ {badge} )} -
+ ) } diff --git a/src/shared/primitives/Badge/Badge.tsx b/src/shared/primitives/Badge/Badge.tsx index 941e5b8..ffa8228 100644 --- a/src/shared/primitives/Badge/Badge.tsx +++ b/src/shared/primitives/Badge/Badge.tsx @@ -2,7 +2,7 @@ import React from 'react' import { cva, type VariantProps } from 'class-variance-authority' const badge = cva( - 'inline-flex items-center gap-1 font-medium rounded-full border transition-opacity', + 'inline-flex items-center justify-center gap-1 font-medium rounded-full border transition-opacity', { variants: { variant: { @@ -26,8 +26,26 @@ const badge = cva( }, size: { - sm: 'px-2 py-0.5 text-xs', - md: 'px-3 py-1 text-sm', + xs: ` + min-w-4 + h-4 + px-1 + text-[10px] +`, + + sm: ` + min-w-6 + h-6 + px-2 + text-xs +`, + + md: ` + min-w-7 + h-7 + px-3 + text-sm +`, }, }, @@ -41,22 +59,32 @@ const badge = cva( export interface BadgeProps extends React.HTMLAttributes, VariantProps { children?: React.ReactNode - dot?: boolean + notification?: boolean } -export function Badge({ children, variant, size, dot = false, className, ...props }: BadgeProps) { - const dotSize = size === 'sm' ? 'w-1.5 h-1.5' : 'w-2 h-2' - +export function Badge({ + children, + variant, + size, + notification = false, + className, + ...props +}: BadgeProps) { + const formattedChildren = typeof children === 'number' && children > 99 ? '99+' : children return ( - {dot ? : children} + {formattedChildren} ) } diff --git a/src/shared/primitives/Tooltip/Tooltip.tsx b/src/shared/primitives/Tooltip/Tooltip.tsx index 888d126..48f919e 100644 --- a/src/shared/primitives/Tooltip/Tooltip.tsx +++ b/src/shared/primitives/Tooltip/Tooltip.tsx @@ -12,6 +12,8 @@ export interface TooltipProps { delay?: number className?: string + + offset?: number } const arrowClasses = { @@ -77,7 +79,7 @@ export function Tooltip({ position = 'top', delay = 400, - + offset = 10, className = '', }: TooltipProps) { const [visible, setVisible] = useState(false) @@ -97,7 +99,7 @@ export function Tooltip({ const rect = triggerRef.current.getBoundingClientRect() - const spacing = 10 + const spacing = offset let top = 0 let left = 0 diff --git a/src/views/LoginPage.tsx b/src/views/LoginPage.tsx index 0a5672c..fa22f7a 100644 --- a/src/views/LoginPage.tsx +++ b/src/views/LoginPage.tsx @@ -1,12 +1,129 @@ import LoginCard from '@/LoginCard' -import React from 'react' + +import { SidebarNav } from '@/shared/composites/SidebarNav' + +import { + CheckIcon, + ExclamationTriangleIcon, + PresentationChartLineIcon, + QueueListIcon, + UsersIcon, +} from '@heroicons/react/24/outline' + +import { useState } from 'react' const LoginPage = () => { - return ( - - - - ) + const [collapsed, setCollapsed] = useState(false) + const [activeItem, setActiveItem] = useState('Queue') + return ( +
+ {/* Sidebar */} + + + {/* Main Content */} +
+ +
+
+ ) } -export default LoginPage \ No newline at end of file +export default LoginPage