Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: SideNav component #107

Merged
merged 3 commits into from
Jan 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions src/components/SideNav/SideNav.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { useState } from "react";

import { action } from "@storybook/addon-actions";
import type { Meta, StoryObj } from "@storybook/react";

import { SideNav, SideNavProps } from "@/components/SideNav";

const handleClick = action("handleClick");

// Wrapper component to handle state
function SideNavWrapper(props: SideNavProps) {
const [activeId, setActiveId] = useState<string | undefined>(props.activeId);
const handleClick = (id: string | undefined) => {
console.log("id", id);
setActiveId(id);
};

return (
<div className="w-[440px] p-10">
<SideNav
{...props}
activeId={activeId}
onClick={handleClick}
accordionProps={{
radius: "sm",
}}
/>
</div>
);
}

const meta: Meta<typeof SideNavWrapper> = {
title: "Components/SideNav",
component: SideNavWrapper,
parameters: {
layout: "centered",
},
};

export default meta;
type Story = StoryObj<typeof SideNavWrapper>;

// Sample navigation items
const sampleItems = [
{
content: "Home",
id: "/home",
items: [],
},
{
content: "My Programs",
id: "/my-programs",
items: [
{
content: "My Program 1",
id: "/my-programs/my-program-1",
items: [],
},
{
content: "My Program 2",
id: "/my-programs/my-program-2",
items: [],
},
],
},
{
content: "My Rounds",
id: "/my-rounds",
items: [
{
content: "Active Rounds",
items: [],
isSeparator: true,
},
{
content: "Cool Round",
id: "/my-rounds/cool-round",
items: [],
},
{
content: "Draft Rounds",
id: "/my-rounds/draft-rounds",
items: [],
isSeparator: true,
},
{
content: "Draft Cool Round",
id: "/my-rounds/draft-cool-round",
items: [],
},
],
},
];

export const Default: Story = {
args: {
items: sampleItems,
onClick: handleClick,
hoverVariant: "grey",
},
};

export const Empty: Story = {
args: {
items: [],
},
};

export const WithCustomClass: Story = {
args: {
items: sampleItems,
className: "bg-gray-100 p-4 rounded-lg",
onClick: handleClick,
},
};
67 changes: 67 additions & 0 deletions src/components/SideNav/SideNav.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { tv } from "tailwind-variants";

import { AccordionItem, NavItem, InternalSideNavProps } from "@/components/SideNav";
import { cn } from "@/lib/utils";

/**
* Tailwind variants for side navigation hover states
*/
export const sideNavVariants = tv({
variants: {
hover: {
white: "hover:bg-white",
grey: "hover:bg-grey-50",
black: "hover:bg-black",
},
},
defaultVariants: {
hover: "white",
},
});

/**
* Renders a recursive side navigation component with support for nested items and accordions.
* @param props InternalSideNavProps
* @returns React component
*/
export const SideNav = ({
items,
className,
hoverVariant = "grey",
accordionProps,
isRecursive = false,
onClick,
activeId,
}: InternalSideNavProps) => {
const hoverVariantClass = sideNavVariants({ hover: hoverVariant });

return (
<div className={cn("relative flex flex-col gap-4", className)}>
{items.map((item, index) => {
const isActive = item.id === activeId;

return (
<div key={`sidenav-item-${index}`}>
{item.items.length > 0 ? (
<AccordionItem
item={item}
onClick={onClick}
hoverVariant={hoverVariant}
accordionProps={accordionProps}
activeId={activeId}
/>
) : (
<NavItem
item={item}
isRecursive={isRecursive}
hoverVariantClass={hoverVariantClass}
onClick={onClick}
isActive={isActive}
/>
)}
</div>
);
})}
</div>
);
};
46 changes: 46 additions & 0 deletions src/components/SideNav/components/AccordionItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { SideNav, SideNavItem, sideNavVariants } from "@/components/SideNav";
import { Accordion, AccordionProps } from "@/primitives/Accordion";
import { Icon } from "@/primitives/Icon";

interface AccordionItemProps {
item: SideNavItem;
onClick: (itemLink: string | undefined) => void;
hoverVariant: keyof typeof sideNavVariants.variants.hover;
accordionProps?: Omit<AccordionProps, "header" | "content">;
activeId?: string;
}

/**
* Renders an accordion item with nested navigation
*/
export const AccordionItem = ({
item,
onClick,
hoverVariant,
accordionProps,
activeId,
}: AccordionItemProps) => (
<Accordion
isOpen={false}
header={
<div className="flex items-center gap-2 font-ui-sans text-p">
{item.iconType ? <Icon type={item.iconType} /> : item.icon}
{item.content}
</div>
}
content={
<SideNav
items={item.items}
className="pl-6"
isRecursive
onClick={onClick}
hoverVariant={hoverVariant}
activeId={activeId}
/>
}
variant="light"
paddingX="sm"
paddingY="sm"
{...accordionProps}
/>
);
47 changes: 47 additions & 0 deletions src/components/SideNav/components/NavItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { SideNavItem } from "@/components/SideNav";
import { cn } from "@/lib/utils";
import { Icon } from "@/primitives/Icon";

interface NavItemProps {
item: SideNavItem;
isRecursive?: boolean;
hoverVariantClass: string;
onClick: (itemLink: string | undefined) => void;
isActive?: boolean;
}

/**
* Renders a clickable navigation item
*/
export const NavItem = ({
item,
isRecursive,
hoverVariantClass,
onClick,
isActive,
}: NavItemProps) => {
const baseClasses = cn(
"cursor-pointer rounded-sm p-2 font-ui-sans text-p",
hoverVariantClass,
isActive && hoverVariantClass.replace("hover:", ""),
item.isSeparator && "cursor-default bg-transparent text-sm font-medium hover:bg-transparent",
);

return (
<div onClick={() => onClick(item.id)}>
{isRecursive ? (
<div className={cn(baseClasses, "-ml-6")}>
<div className="flex items-center gap-2 pl-6">
{item.iconType ? <Icon type={item.iconType} /> : item.icon}
{item.content}
</div>
</div>
) : (
<div className={cn(baseClasses, "flex items-center gap-2")}>
{item.iconType ? <Icon type={item.iconType} /> : item.icon}
{item.content}
</div>
)}
</div>
);
};
2 changes: 2 additions & 0 deletions src/components/SideNav/components/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./AccordionItem";
export * from "./NavItem";
3 changes: 3 additions & 0 deletions src/components/SideNav/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./SideNav";
export * from "./types";
export * from "./components";
25 changes: 25 additions & 0 deletions src/components/SideNav/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { AccordionProps } from "@/primitives/Accordion";
import { IconType } from "@/primitives/Icon";

import { sideNavVariants } from "./SideNav";

export interface SideNavItem {
content: React.ReactNode;
icon?: React.ReactNode;
iconType?: IconType;
id?: string;
isSeparator?: boolean;
items: SideNavItem[];
}

export interface InternalSideNavProps {
items: SideNavItem[];
onClick: (id: string | undefined) => void;
activeId?: string;
className?: string;
accordionProps?: Omit<AccordionProps, "header" | "content">;
hoverVariant?: keyof typeof sideNavVariants.variants.hover;
isRecursive?: boolean;
}

export type SideNavProps = Omit<InternalSideNavProps, "isRecursive">;
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export const ApplicationSummary = ({
content={<ProjectSummary projectMetadata={project} application={application} />}
variant="default"
border="none"
padding="none"
paddingX="sm"
/>
)}

Expand All @@ -98,7 +98,7 @@ export const ApplicationSummary = ({
content={<Markdown>{project.description}</Markdown>}
variant="default"
border="none"
padding="none"
paddingX="sm"
isOpen={false}
/>
)}
Expand Down Expand Up @@ -134,7 +134,7 @@ export const ApplicationSummary = ({
}
variant="default"
border="none"
padding="none"
paddingX="sm"
isOpen={false}
/>
)}
Expand Down Expand Up @@ -164,7 +164,7 @@ export const ApplicationSummary = ({
}
variant="default"
border="none"
padding="none"
paddingX="sm"
isOpen={false}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export const ReviewDropdown: React.FC<ReviewDropdownContentProps> = ({
return (
<Accordion
border="md"
padding="lg"
paddingX="lg"
variant={accordionVariant}
header={<ReviewDropdownHeader evaluation={evaluation} index={index} />}
content={<ReviewDropdownContent evaluation={evaluation} />}
Expand Down
8 changes: 4 additions & 4 deletions src/primitives/Accordion/Accordion.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const meta: Meta<typeof Accordion> = {
control: "select",
description: "Border style of the accordion",
},
padding: {
paddingX: {
options: ["none", "sm", "md", "lg"],
control: "select",
description: "Padding style of the accordion",
Expand Down Expand Up @@ -48,7 +48,7 @@ export const Light: Story = {
content: "Simple Content",
variant: "light",
border: "md",
padding: "lg",
paddingX: "xl",
isOpen: false,
},
};
Expand All @@ -59,7 +59,7 @@ export const Blue: Story = {
content: "Simple Content",
variant: "blue",
border: "md",
padding: "lg",
paddingX: "xl",
isOpen: true,
},
};
Expand Down Expand Up @@ -109,7 +109,7 @@ export const coolProject: Story = {
),
variant: "default",
border: "sm",
padding: "none",
paddingX: "sm",
isOpen: false,
},
};
Loading
Loading