Skip to content

feat: sidebar compound components #11

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

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/style.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,4 @@ jobs:
run: bun format

- name: Lint the code
run: bun lint
run: bun lint
Binary file added bun.lockb
Binary file not shown.
117 changes: 117 additions & 0 deletions src/components/sidebar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"use client";

import Link from "next/link";
import { useState, createContext, useContext } from "react";

interface IParentProps {
children: React.ReactNode;
className?: string;
}

interface ISideBarProps extends IParentProps {
defaultSelected?: string;
}

interface ISideBarContextType {
selected: string | null;
setSelected: React.Dispatch<React.SetStateAction<string | null>>;
}

interface IItemProps extends IParentProps {
id: string;
href?: string;
onClick?: () => void;
}

// Compound Components

const SideBarContext = createContext<ISideBarContextType | null>(null);

export function SidebarHeader({ children, className }: IParentProps) {
return (
<div
className={`mx-3 mb-2.5 border-b border-gray-200 pt-5 pb-3 text-[#00000080] ${className}`}
>
{children}
</div>
);
}

export function SidebarItemList({ children, className }: IParentProps) {
return <div className={`flex flex-col gap-2 ${className}`}>{children}</div>;
}

export function SidebarItem({
children,
className,
id,
href,
onClick,
}: IItemProps) {
const context = useContext(SideBarContext);

if (!context) {
throw new Error("Item must be used within a Sidebar");
}

const { selected, setSelected } = context;
const isSelected = selected === id;

const commonClass = `flex group cursor-pointer items-center font-medium rounded-lg px-3 py-2.5 text-dark/50 transition-all duration-300 ease-in-out ${isSelected ? "bg-primary/10 text-primary ring-1 ring-primary/25" : "hover:bg-gray-100"} ${className}`;

return href ? (
<Link
href={href}
onClick={() => setSelected(id)}
className={commonClass}
aria-current={isSelected ? "page" : undefined}
>
{children}
</Link>
) : (
<button
onClick={() => {
setSelected(id);
onClick?.();
}}
className={commonClass}
aria-current={isSelected ? "true" : "false"}
>
{children}
</button>
);
}

export function SidebarItemLabel({
icon,
label,
}: {
icon: string;
label: string;
}) {
return (
<div className="inline-flex gap-2">
<span className="material-symbols-outlined-filled origin-center transition-all duration-200 ease-in-out group-hover:scale-105 group-hover:-rotate-12">
{icon}
</span>
<p className="transition-all duration-200 ease-in-out group-hover:translate-x-1">
{label}
</p>
</div>
);
}

// Main Sidebar component
export default function Sidebar({ children, defaultSelected }: ISideBarProps) {
const [selected, setSelected] = useState<string | null>(
defaultSelected || null,
);

return (
<div className="h-full px-0.5 py-2">
<SideBarContext.Provider value={{ selected, setSelected }}>
{children}
</SideBarContext.Provider>
</div>
);
}
103 changes: 103 additions & 0 deletions src/stories/sidebar/Sidebar.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import Sidebar, {
SidebarHeader,
SidebarItem,
SidebarItemLabel,
SidebarItemList,
} from "@/components/sidebar";

const meta: Meta<typeof Sidebar> = {
title: "Components/Sidebar",
component: Sidebar,
parameters: {
layout: "fullscreen",
docs: {
description: {
component:
"A flexible sidebar container that manages selection state and provides context for child components. Serves as the root wrapper that coordinates the active state across all sidebar items.",
},
},
},
tags: ["autodocs"],
argTypes: {
defaultSelected: {
control: "text",
description:
"ID of the item that should be selected by default when the component loads",
},
children: {
control: false,
description:
"Sidebar child elements that compose the internal content of the sidebar",
},
className: {
control: "text",
description:
"Additional CSS classes to customize the styling of the main container",
},
},
};

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

export const Default: Story = {
render: () => (
<div className="max-w-xs p-4">
<Sidebar defaultSelected="account">
<SidebarHeader>Conta</SidebarHeader>

<SidebarItemList>
<SidebarItem id="account" href="/account">
<SidebarItemLabel icon="account_circle" label="A tua conta" />
</SidebarItem>
<SidebarItem id="university" href="/uminho">
<SidebarItemLabel icon="school" label="Universidade do Minho" />
</SidebarItem>
<SidebarItem id="privacy" href="/privacy">
<SidebarItemLabel
icon="back_hand"
label="Privacidade e segurança"
/>
</SidebarItem>
<SidebarItem id="connections" href="/connections">
<SidebarItemLabel icon="handshake" label="Ligações" />
</SidebarItem>
</SidebarItemList>
</Sidebar>
</div>
),
};

export const WithoutLabels: Story = {
render: () => (
<div className="max-w-xs p-4">
<Sidebar defaultSelected="account">
<SidebarHeader>Conta</SidebarHeader>

<SidebarItemList>
<SidebarItem id="account" href="/account">
<p className="transition-all duration-200 ease-in-out group-hover:translate-x-1">
A tua conta
</p>
</SidebarItem>
<SidebarItem id="university" href="/uminho">
<p className="transition-all duration-200 ease-in-out group-hover:translate-x-1">
Universidade do Minho
</p>
</SidebarItem>
<SidebarItem id="privacy" href="/privacy">
<p className="transition-all duration-200 ease-in-out group-hover:translate-x-1">
Privacidade e segurança
</p>
</SidebarItem>
<SidebarItem id="connections" href="/connections">
<p className="transition-all duration-200 ease-in-out group-hover:translate-x-1">
Ligações
</p>
</SidebarItem>
</SidebarItemList>
</Sidebar>
</div>
),
};
41 changes: 41 additions & 0 deletions src/stories/sidebar/SidebarHeader.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import Sidebar, { SidebarHeader } from "@/components/sidebar";

const meta: Meta<typeof SidebarHeader> = {
title: "Components/Sidebar/SidebarHeader",
component: SidebarHeader,
parameters: {
layout: "fullscreen",
docs: {
description: {
component:
"A styled header section for the sidebar with bottom border separation. Typically used to display titles.",
},
},
},
tags: ["autodocs"],
argTypes: {
children: {
control: false,
description: "Header content. Typically a title",
},
className: {
control: false,
description:
"Additional CSS classes to override or extend the default header styling",
},
},
};

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

export const Default: Story = {
render: () => (
<div className="max-w-xs p-4">
<Sidebar>
<SidebarHeader>Hello World!</SidebarHeader>
</Sidebar>
</div>
),
};
56 changes: 56 additions & 0 deletions src/stories/sidebar/SidebarItem.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import Sidebar, { SidebarItem } from "@/components/sidebar";

const meta: Meta<typeof SidebarItem> = {
title: "Components/Sidebar/SidebarItem",
component: SidebarItem,
parameters: {
layout: "fullscreen",
docs: {
description: {
component:
"An interactive menu item that can function as either a navigation link or button. Handles selection state, hover effects, and supports both Next.js routing and custom click handlers with visual feedback.",
},
},
},
tags: ["autodocs"],
argTypes: {
children: {
control: false,
description: "Internal content of the item",
},
id: {
control: "text",
description:
"Required unique identifier used to control which item is active/selected",
},
href: {
control: "text",
description:
"Destination URL, when provided transforms the item into a Next.js Link",
},
onClick: {
control: false,
description:
"Callback executed on click, when provided transforms the item into a button",
},
className: {
control: false,
description:
"Additional CSS classes to customize individual item appearance",
},
},
};

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

export const Default: Story = {
render: () => (
<div className="max-w-xs p-4">
<Sidebar>
<SidebarItem id="example">Selectable Item</SidebarItem>
</Sidebar>
</div>
),
};
49 changes: 49 additions & 0 deletions src/stories/sidebar/SidebarItemLabel.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import Sidebar, {
SidebarItem,
SidebarItemLabel,
SidebarItemList,
} from "@/components/sidebar";

const meta: Meta<typeof SidebarItemLabel> = {
title: "Components/Sidebar/SidebarItemLabel",
component: SidebarItemLabel,
parameters: {
layout: "fullscreen",
docs: {
description: {
component:
"A label component that displays an icon and text for sidebar items, featuring hover animations. Created to support sidebar usage on main pages development.",
},
},
},
tags: ["autodocs"],
argTypes: {
icon: {
control: false,
description:
"Material Symbols icon name that will be displayed next to the text",
},
label: {
control: false,
description: "Descriptive text of the item that appears after the icon",
},
},
};

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

export const Default: Story = {
render: () => (
<div className="max-w-xs p-4">
<Sidebar>
<SidebarItemList>
<SidebarItem id="example">
<SidebarItemLabel icon="hand_gesture" label="Hello World" />
</SidebarItem>
</SidebarItemList>
</Sidebar>
</div>
),
};
Loading