sidebar component completed

This commit is contained in:
Nate Kelley 2025-02-27 22:53:43 -07:00
parent 5169b4eb92
commit f59225addb
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
6 changed files with 233 additions and 29 deletions

View File

@ -0,0 +1,150 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Sidebar } from './Sidebar';
import { BusterRoutes } from '@/routes';
import { Window, WindowUser, WindowSettings, WindowAlert } from '../icons/NucleoIconOutlined';
const meta: Meta<typeof Sidebar> = {
title: 'UI/Sidebar/Sidebar',
component: Sidebar,
decorators: [
(Story) => (
<div className="h-[500px] w-64 bg-transparent">
<Story />
</div>
)
]
};
export default meta;
type Story = StoryObj<typeof Sidebar>;
const mockItems = [
{
id: '1',
label: 'Home',
icon: <Window />,
route: BusterRoutes.APP_ROOT
},
{
id: '2',
label: 'Profile',
icon: <WindowUser />,
route: BusterRoutes.SETTINGS_PROFILE
},
{
id: '3',
label: 'Settings',
icon: <WindowSettings />,
route: BusterRoutes.SETTINGS
}
];
const mockGroupedContent = [
{
items: [
{
id: '4',
label: 'Notifications',
icon: <WindowAlert width="1.25em" height="1.25em" />,
route: BusterRoutes.SETTINGS_NOTIFICATIONS
},
{
id: '5',
label: 'Notifications',
icon: <WindowAlert width="1.25em" height="1.25em" />,
route: BusterRoutes.SETTINGS_NOTIFICATIONS
}
]
},
{
label: 'Main Menu',
items: mockItems
}
];
export const Default: Story = {
args: {
header: <div className="text-xl font-semibold">My App</div>,
content: mockGroupedContent,
footer: (
<div className="flex h-full items-center justify-center text-sm text-gray-500">Footer</div>
),
activeItem: '1'
}
};
export const WithLongContent: Story = {
args: {
header: <div className="text-xl font-semibold">My App</div>,
content: [
{
label: 'Main Menu',
items: [...Array(20)].map((_, i) => ({
id: `item-${i}`,
label: `Menu Item ${i + 1}`,
icon: <Window width="1.25em" height="1.25em" />,
route: BusterRoutes.APP_ROOT
}))
}
],
footer: <div className="text-sm text-gray-500">Sticky Footer</div>,
activeItem: 'item-1'
}
};
export const NoFooter: Story = {
args: {
header: <div className="text-xl font-semibold">My App</div>,
content: mockGroupedContent,
activeItem: '1'
}
};
export const ScrollAndTruncationTest: Story = {
args: {
header: <div className="text-xl font-semibold">Scroll & Truncation Test</div>,
activeItem: 'long-4',
content: [
{
items: mockItems
},
{
label: 'Short Items',
items: [...Array(20)].map((_, i) => ({
id: `short-${i}`,
label: `Item ${i + 1}`,
icon: <Window width="1.25em" height="1.25em" />,
route: BusterRoutes.APP_ROOT
}))
},
{
label: 'Long Items',
items: [
{
id: 'long-1',
label:
'This is an extremely long menu item that should definitely get truncated in the UI because it is way too long to fit',
icon: <WindowSettings width="1.25em" height="1.25em" />,
route: BusterRoutes.SETTINGS
},
{
id: 'long-2',
label:
'Another very long label that contains some technical terms like Implementation Documentation Requirements',
icon: <WindowUser width="1.25em" height="1.25em" />,
route: BusterRoutes.SETTINGS_PROFILE
},
...Array(30)
.fill(null)
.map((_, i) => ({
id: `long-${i + 3}`,
label: `Somewhat Long Menu Item ${i + 1} with Additional Description Text`,
icon: <WindowAlert width="1.25em" height="1.25em" />,
route: BusterRoutes.SETTINGS_NOTIFICATIONS
}))
]
}
],
footer: <div className="text-sm text-gray-500">Footer for Scroll Test</div>
}
};

View File

@ -0,0 +1,50 @@
import React from 'react';
import { ISidebarGroup, ISidebarList, SidebarProps } from './interfaces';
import { SidebarCollapsible } from './SidebarCollapsible';
import { SidebarItem } from './SidebarItem';
export const Sidebar: React.FC<SidebarProps> = React.memo(
({ header, content, footer, activeItem }) => {
return (
<div className="flex h-full flex-col overflow-hidden px-3.5 pt-4.5">
<div className="flex flex-col space-y-4.5 overflow-hidden">
<div className="mb-5"> {header}</div>
<div className="flex flex-grow flex-col space-y-4.5 overflow-y-auto pb-3">
{content.map((item, index) => (
<ContentSelector key={index} content={item} activeItem={activeItem} />
))}
</div>
</div>
{footer && <div className="mt-auto bg-red-200 pt-5">{footer}</div>}
</div>
);
}
);
const ContentSelector: React.FC<{
content: SidebarProps['content'][number];
activeItem: SidebarProps['activeItem'];
}> = React.memo(({ content, activeItem }) => {
if (isSidebarGroup(content)) {
return <SidebarCollapsible {...content} activeItem={activeItem} />;
}
return <SidebarList items={content.items} activeItem={activeItem} />;
});
const SidebarList: React.FC<{
items: ISidebarList['items'];
activeItem: SidebarProps['activeItem'];
}> = React.memo(({ items, activeItem }) => {
return (
<div className="flex flex-col space-y-0.5">
{items.map((item) => (
<SidebarItem key={item.id} {...item} active={activeItem === item.id || item.active} />
))}
</div>
);
});
const isSidebarGroup = (content: SidebarProps['content'][number]): content is ISidebarGroup => {
return 'label' in content && content.label !== undefined;
};

View File

@ -1,12 +1,12 @@
import type { Meta, StoryObj } from '@storybook/react';
import { SidebarGroup } from './SidebarCollapsible';
import { SidebarCollapsible } from './SidebarCollapsible';
//import { Home, Settings, User } from 'lucide-react';
import { HouseModern, MapSettings, User } from '../icons/NucleoIconOutlined';
import { BusterRoutes } from '@/routes';
const meta: Meta<typeof SidebarGroup> = {
title: 'UI/Sidebar/SidebarGroup',
component: SidebarGroup,
const meta: Meta<typeof SidebarCollapsible> = {
title: 'UI/Sidebar/SidebarCollapsible',
component: SidebarCollapsible,
parameters: {
layout: 'centered'
},
@ -20,7 +20,7 @@ const meta: Meta<typeof SidebarGroup> = {
};
export default meta;
type Story = StoryObj<typeof SidebarGroup>;
type Story = StoryObj<typeof SidebarCollapsible>;
export const Default: Story = {
args: {

View File

@ -19,15 +19,14 @@ const SidebarTrigger: React.FC<SidebarTriggerProps> = React.memo(({ label, isOpe
<div
className={cn(
'flex items-center gap-1 rounded px-1.5 py-1 text-sm transition-colors',
'hover:text-text-default text-text-secondary',
'text-text-secondary hover:bg-nav-item-hover',
'group cursor-pointer'
)}>
<span className="">{label}</span>
<div
className={cn(
'text-icon-color text-2xs -rotate-90 transition-transform duration-200',
isOpen && 'rotate-0',
'group-hover:text-text-default'
isOpen && 'rotate-0'
)}>
<CaretDown />
</div>
@ -35,23 +34,27 @@ const SidebarTrigger: React.FC<SidebarTriggerProps> = React.memo(({ label, isOpe
);
});
export const SidebarGroup: React.FC<ISidebarGroup> = React.memo(({ label, items }) => {
const [isOpen, setIsOpen] = React.useState(false);
export const SidebarCollapsible: React.FC<ISidebarGroup & { activeItem?: string }> = React.memo(
({ label, items, activeItem, defaultOpen = true }) => {
const [isOpen, setIsOpen] = React.useState(defaultOpen);
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="space-y-0.5">
<CollapsibleTrigger asChild className="w-full">
<button className="w-full text-left">
<SidebarTrigger label={label} isOpen={isOpen} />
</button>
</CollapsibleTrigger>
<CollapsibleContent className="data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up pl-0">
<div className="space-y-0.5">
{items.map((item) => (
<SidebarItem key={item.id} {...item} />
))}
</div>
</CollapsibleContent>
</Collapsible>
);
});
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="space-y-0.5">
<CollapsibleTrigger asChild className="w-full">
<button className="w-full text-left">
<SidebarTrigger label={label} isOpen={isOpen} />
</button>
</CollapsibleTrigger>
<CollapsibleContent className="data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up pl-0">
<div className="space-y-0.5">
{items.map((item) => (
<SidebarItem key={item.id} {...item} active={activeItem === item.id || item.active} />
))}
</div>
</CollapsibleContent>
</Collapsible>
);
}
);
SidebarCollapsible.displayName = 'SidebarCollapsible';

View File

@ -9,11 +9,11 @@ const itemVariants = cva(
{
variants: {
variant: {
default: 'hover:bg-item-hover text-text-default',
default: 'hover:bg-nav-item-hover text-text-default',
emphasized: 'shadow bg-background border border-border text-text-default'
},
active: {
true: '',
true: 'cursor-default',
false: ''
},
disabled: {

View File

@ -13,6 +13,7 @@ export interface ISidebarItem {
export interface ISidebarGroup {
label: string;
items: ISidebarItem[];
defaultOpen?: boolean; //will default to true
}
export interface ISidebarList {