diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index 17c3e2b..116e470 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -28,4 +28,4 @@ jobs: run: bun format - name: Lint the code - run: bun lint \ No newline at end of file + run: bun lint diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..47c2338 Binary files /dev/null and b/bun.lockb differ diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx new file mode 100644 index 0000000..f266bdb --- /dev/null +++ b/src/components/sidebar.tsx @@ -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>; +} + +interface IItemProps extends IParentProps { + id: string; + href?: string; + onClick?: () => void; +} + +// Compound Components + +const SideBarContext = createContext(null); + +export function SidebarHeader({ children, className }: IParentProps) { + return ( +
+ {children} +
+ ); +} + +export function SidebarItemList({ children, className }: IParentProps) { + return
{children}
; +} + +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 ? ( + setSelected(id)} + className={commonClass} + aria-current={isSelected ? "page" : undefined} + > + {children} + + ) : ( + + ); +} + +export function SidebarItemLabel({ + icon, + label, +}: { + icon: string; + label: string; +}) { + return ( +
+ + {icon} + +

+ {label} +

+
+ ); +} + +// Main Sidebar component +export default function Sidebar({ children, defaultSelected }: ISideBarProps) { + const [selected, setSelected] = useState( + defaultSelected || null, + ); + + return ( +
+ + {children} + +
+ ); +} diff --git a/src/stories/sidebar/Sidebar.stories.tsx b/src/stories/sidebar/Sidebar.stories.tsx new file mode 100644 index 0000000..eebfadc --- /dev/null +++ b/src/stories/sidebar/Sidebar.stories.tsx @@ -0,0 +1,103 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import Sidebar, { + SidebarHeader, + SidebarItem, + SidebarItemLabel, + SidebarItemList, +} from "@/components/sidebar"; + +const meta: Meta = { + 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; + +export const Default: Story = { + render: () => ( +
+ + Conta + + + + + + + + + + + + + + + + +
+ ), +}; + +export const WithoutLabels: Story = { + render: () => ( +
+ + Conta + + + +

+ A tua conta +

+
+ +

+ Universidade do Minho +

+
+ +

+ Privacidade e segurança +

+
+ +

+ Ligações +

+
+
+
+
+ ), +}; diff --git a/src/stories/sidebar/SidebarHeader.stories.tsx b/src/stories/sidebar/SidebarHeader.stories.tsx new file mode 100644 index 0000000..8c27611 --- /dev/null +++ b/src/stories/sidebar/SidebarHeader.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import Sidebar, { SidebarHeader } from "@/components/sidebar"; + +const meta: Meta = { + 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; + +export const Default: Story = { + render: () => ( +
+ + Hello World! + +
+ ), +}; diff --git a/src/stories/sidebar/SidebarItem.stories.tsx b/src/stories/sidebar/SidebarItem.stories.tsx new file mode 100644 index 0000000..b02afc1 --- /dev/null +++ b/src/stories/sidebar/SidebarItem.stories.tsx @@ -0,0 +1,56 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import Sidebar, { SidebarItem } from "@/components/sidebar"; + +const meta: Meta = { + 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; + +export const Default: Story = { + render: () => ( +
+ + Selectable Item + +
+ ), +}; diff --git a/src/stories/sidebar/SidebarItemLabel.stories.tsx b/src/stories/sidebar/SidebarItemLabel.stories.tsx new file mode 100644 index 0000000..c5828f9 --- /dev/null +++ b/src/stories/sidebar/SidebarItemLabel.stories.tsx @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import Sidebar, { + SidebarItem, + SidebarItemLabel, + SidebarItemList, +} from "@/components/sidebar"; + +const meta: Meta = { + 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; + +export const Default: Story = { + render: () => ( +
+ + + + + + + +
+ ), +}; diff --git a/src/stories/sidebar/SidebarItemList.stories.tsx b/src/stories/sidebar/SidebarItemList.stories.tsx new file mode 100644 index 0000000..bc36a11 --- /dev/null +++ b/src/stories/sidebar/SidebarItemList.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import Sidebar, { SidebarItem, SidebarItemList } from "@/components/sidebar"; + +const meta: Meta = { + title: "Components/Sidebar/SidebarItemList", + component: SidebarItemList, + parameters: { + layout: "fullscreen", + docs: { + description: { + component: + "A vertical container that organizes sidebar items with consistent spacing.", + }, + }, + }, + tags: ["autodocs"], + argTypes: { + children: { + control: false, + description: + "List of SidebarItem components that form the navigable menu items", + }, + className: { + control: "text", + description: "Additional CSS classes", + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( +
+ + + Hello World! + + +
+ ), +};