@fabio.caffarello/react-design-system 1.5.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fabio.caffarello/react-design-system",
3
3
  "private": false,
4
- "version": "1.5.2",
4
+ "version": "1.6.0",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.js",
@@ -0,0 +1,124 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import Collapsible from "./Collapsible";
4
+ import { Button, Text } from "../../atoms";
5
+
6
+ const meta: Meta<typeof Collapsible> = {
7
+ title: "UI/Atoms/Collapsible",
8
+ component: Collapsible,
9
+ parameters: {
10
+ docs: {
11
+ description: {
12
+ component: "A generic, reusable collapsible component for any content. Supports both controlled and uncontrolled modes.",
13
+ },
14
+ },
15
+ },
16
+ argTypes: {
17
+ defaultOpen: {
18
+ control: "boolean",
19
+ description: "Initial open state (uncontrolled mode)",
20
+ },
21
+ disabled: {
22
+ control: "boolean",
23
+ description: "Whether the collapsible is disabled",
24
+ },
25
+ duration: {
26
+ control: "number",
27
+ description: "Animation duration in milliseconds",
28
+ },
29
+ },
30
+ };
31
+
32
+ export const Default: StoryObj<typeof Collapsible> = {
33
+ args: {
34
+ defaultOpen: true,
35
+ trigger: (
36
+ <div className="px-4 py-2 bg-gray-100 rounded-md">
37
+ <Text as="span" className="font-medium">Click to toggle</Text>
38
+ </div>
39
+ ),
40
+ children: (
41
+ <div className="px-4 py-2">
42
+ <Text>This is collapsible content that can be shown or hidden.</Text>
43
+ </div>
44
+ ),
45
+ },
46
+ };
47
+
48
+ export const DefaultClosed: StoryObj<typeof Collapsible> = {
49
+ args: {
50
+ defaultOpen: false,
51
+ trigger: (
52
+ <div className="px-4 py-2 bg-gray-100 rounded-md">
53
+ <Text as="span" className="font-medium">Click to expand</Text>
54
+ </div>
55
+ ),
56
+ children: (
57
+ <div className="px-4 py-2">
58
+ <Text>This content starts collapsed.</Text>
59
+ </div>
60
+ ),
61
+ },
62
+ };
63
+
64
+ export const Controlled: StoryObj<typeof Collapsible> = {
65
+ render: () => {
66
+ const [isOpen, setIsOpen] = useState(false);
67
+ return (
68
+ <div className="space-y-4">
69
+ <Button onClick={() => setIsOpen(!isOpen)}>
70
+ {isOpen ? "Close" : "Open"} (External Control)
71
+ </Button>
72
+ <Collapsible
73
+ open={isOpen}
74
+ onOpenChange={setIsOpen}
75
+ trigger={
76
+ <div className="px-4 py-2 bg-gray-100 rounded-md">
77
+ <Text as="span" className="font-medium">Controlled Collapsible</Text>
78
+ </div>
79
+ }
80
+ >
81
+ <div className="px-4 py-2">
82
+ <Text>This collapsible is controlled by external state.</Text>
83
+ </div>
84
+ </Collapsible>
85
+ </div>
86
+ );
87
+ },
88
+ };
89
+
90
+ export const WithStorage: StoryObj<typeof Collapsible> = {
91
+ args: {
92
+ defaultOpen: true,
93
+ storageKey: "storybook-collapsible-state",
94
+ trigger: (
95
+ <div className="px-4 py-2 bg-gray-100 rounded-md">
96
+ <Text as="span" className="font-medium">State persists in localStorage</Text>
97
+ </div>
98
+ ),
99
+ children: (
100
+ <div className="px-4 py-2">
101
+ <Text>Toggle this and refresh the page - the state will be preserved!</Text>
102
+ </div>
103
+ ),
104
+ },
105
+ };
106
+
107
+ export const Disabled: StoryObj<typeof Collapsible> = {
108
+ args: {
109
+ defaultOpen: true,
110
+ disabled: true,
111
+ trigger: (
112
+ <div className="px-4 py-2 bg-gray-100 rounded-md opacity-50">
113
+ <Text as="span" className="font-medium">Disabled (cannot toggle)</Text>
114
+ </div>
115
+ ),
116
+ children: (
117
+ <div className="px-4 py-2">
118
+ <Text>This content cannot be toggled.</Text>
119
+ </div>
120
+ ),
121
+ },
122
+ };
123
+
124
+ export default meta;
@@ -0,0 +1,174 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3
+ import Collapsible from './Collapsible';
4
+
5
+ describe('Collapsible', () => {
6
+ beforeEach(() => {
7
+ // Clear localStorage before each test
8
+ localStorage.clear();
9
+ });
10
+
11
+ afterEach(() => {
12
+ localStorage.clear();
13
+ });
14
+
15
+ it('renders trigger and children', () => {
16
+ render(
17
+ <Collapsible
18
+ trigger={<button>Toggle</button>}
19
+ defaultOpen={true}
20
+ >
21
+ <div>Content</div>
22
+ </Collapsible>
23
+ );
24
+
25
+ expect(screen.getByText('Toggle')).toBeInTheDocument();
26
+ expect(screen.getByText('Content')).toBeInTheDocument();
27
+ });
28
+
29
+ it('starts open when defaultOpen is true', () => {
30
+ render(
31
+ <Collapsible
32
+ trigger={<button>Toggle</button>}
33
+ defaultOpen={true}
34
+ >
35
+ <div>Content</div>
36
+ </Collapsible>
37
+ );
38
+
39
+ const content = screen.getByText('Content').parentElement;
40
+ expect(content).toHaveAttribute('aria-hidden', 'false');
41
+ });
42
+
43
+ it('starts closed when defaultOpen is false', () => {
44
+ render(
45
+ <Collapsible
46
+ trigger={<button>Toggle</button>}
47
+ defaultOpen={false}
48
+ >
49
+ <div>Content</div>
50
+ </Collapsible>
51
+ );
52
+
53
+ const content = screen.getByText('Content').parentElement;
54
+ expect(content).toHaveAttribute('aria-hidden', 'true');
55
+ });
56
+
57
+ it('toggles content when trigger is clicked', async () => {
58
+ render(
59
+ <Collapsible
60
+ trigger={<button>Toggle</button>}
61
+ defaultOpen={true}
62
+ >
63
+ <div>Content</div>
64
+ </Collapsible>
65
+ );
66
+
67
+ const button = screen.getByText('Toggle');
68
+ const content = screen.getByText('Content').parentElement;
69
+
70
+ expect(content).toHaveAttribute('aria-hidden', 'false');
71
+
72
+ fireEvent.click(button);
73
+
74
+ await waitFor(() => {
75
+ expect(content).toHaveAttribute('aria-hidden', 'true');
76
+ });
77
+ });
78
+
79
+ it('calls onOpenChange when provided (controlled mode)', () => {
80
+ const handleOpenChange = vi.fn();
81
+ render(
82
+ <Collapsible
83
+ trigger={<button>Toggle</button>}
84
+ open={true}
85
+ onOpenChange={handleOpenChange}
86
+ >
87
+ <div>Content</div>
88
+ </Collapsible>
89
+ );
90
+
91
+ const button = screen.getByText('Toggle');
92
+ fireEvent.click(button);
93
+
94
+ expect(handleOpenChange).toHaveBeenCalledWith(false);
95
+ });
96
+
97
+ it('persists state in localStorage when storageKey is provided', async () => {
98
+ const storageKey = 'test-collapsible';
99
+
100
+ const { rerender } = render(
101
+ <Collapsible
102
+ trigger={<button>Toggle</button>}
103
+ defaultOpen={true}
104
+ storageKey={storageKey}
105
+ >
106
+ <div>Content</div>
107
+ </Collapsible>
108
+ );
109
+
110
+ const button = screen.getByText('Toggle');
111
+ fireEvent.click(button);
112
+
113
+ await waitFor(() => {
114
+ expect(localStorage.getItem(storageKey)).toBe('false');
115
+ });
116
+
117
+ // Re-render and check state is restored
118
+ rerender(
119
+ <Collapsible
120
+ trigger={<button>Toggle</button>}
121
+ defaultOpen={true}
122
+ storageKey={storageKey}
123
+ >
124
+ <div>Content</div>
125
+ </Collapsible>
126
+ );
127
+
128
+ const content = screen.getByText('Content').parentElement;
129
+ expect(content).toHaveAttribute('aria-hidden', 'true');
130
+ });
131
+
132
+ it('does not toggle when disabled', async () => {
133
+ render(
134
+ <Collapsible
135
+ trigger={<button>Toggle</button>}
136
+ defaultOpen={true}
137
+ disabled={true}
138
+ >
139
+ <div>Content</div>
140
+ </Collapsible>
141
+ );
142
+
143
+ const button = screen.getByText('Toggle');
144
+ const content = screen.getByText('Content').parentElement;
145
+
146
+ expect(button).toBeDisabled();
147
+ expect(content).toHaveAttribute('aria-hidden', 'false');
148
+
149
+ fireEvent.click(button);
150
+
151
+ // State should not change
152
+ await waitFor(() => {
153
+ expect(content).toHaveAttribute('aria-hidden', 'false');
154
+ }, { timeout: 100 });
155
+ });
156
+
157
+ it('has correct ARIA attributes', () => {
158
+ render(
159
+ <Collapsible
160
+ trigger={<button>Toggle</button>}
161
+ defaultOpen={true}
162
+ >
163
+ <div>Content</div>
164
+ </Collapsible>
165
+ );
166
+
167
+ const button = screen.getByText('Toggle');
168
+ const content = screen.getByText('Content').parentElement?.parentElement;
169
+
170
+ expect(button).toHaveAttribute('aria-expanded', 'true');
171
+ expect(button).toHaveAttribute('aria-controls');
172
+ expect(content).toHaveAttribute('id');
173
+ });
174
+ });
@@ -0,0 +1,115 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState, type HTMLAttributes, type ReactNode } from 'react';
4
+ import { useCollapsible, type UseCollapsibleOptions } from '../../hooks/useCollapsible';
5
+
6
+ export interface CollapsibleProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
7
+ children: ReactNode;
8
+ trigger: ReactNode; // Content for the toggle button
9
+ defaultOpen?: boolean;
10
+ open?: boolean; // Controlled mode
11
+ onOpenChange?: (open: boolean) => void;
12
+ disabled?: boolean;
13
+ duration?: number; // Animation duration in ms
14
+ storageKey?: string; // For localStorage persistence
15
+ }
16
+
17
+ /**
18
+ * Collapsible Component
19
+ *
20
+ * A generic, reusable collapsible component for any content.
21
+ * Supports both controlled and uncontrolled modes.
22
+ * Includes smooth animations and full ARIA support.
23
+ *
24
+ * @example
25
+ * ```tsx
26
+ * <Collapsible
27
+ * trigger={<button>Toggle</button>}
28
+ * defaultOpen={true}
29
+ * >
30
+ * <div>Collapsible content</div>
31
+ * </Collapsible>
32
+ * ```
33
+ */
34
+ export default function Collapsible({
35
+ children,
36
+ trigger,
37
+ defaultOpen = true,
38
+ open,
39
+ onOpenChange,
40
+ disabled = false,
41
+ duration = 200,
42
+ storageKey,
43
+ className = '',
44
+ ...props
45
+ }: CollapsibleProps) {
46
+ const { isOpen, toggle, setOpen } = useCollapsible({
47
+ defaultOpen,
48
+ open,
49
+ onOpenChange,
50
+ storageKey,
51
+ } as UseCollapsibleOptions);
52
+
53
+ const contentRef = useRef<HTMLDivElement>(null);
54
+ const [height, setHeight] = useState<number | 'auto'>(isOpen ? 'auto' : 0);
55
+
56
+ // Update height when content changes or isOpen changes
57
+ useEffect(() => {
58
+ if (!contentRef.current) return;
59
+
60
+ if (isOpen) {
61
+ // Set to actual height for animation
62
+ setHeight(contentRef.current.scrollHeight);
63
+ } else {
64
+ // Set to 0 for collapse animation
65
+ setHeight(0);
66
+ }
67
+ }, [isOpen, children]);
68
+
69
+ // Handle resize to recalculate height
70
+ useEffect(() => {
71
+ if (!isOpen || !contentRef.current) return;
72
+
73
+ const resizeObserver = new ResizeObserver(() => {
74
+ if (contentRef.current && isOpen) {
75
+ setHeight(contentRef.current.scrollHeight);
76
+ }
77
+ });
78
+
79
+ resizeObserver.observe(contentRef.current);
80
+
81
+ return () => {
82
+ resizeObserver.disconnect();
83
+ };
84
+ }, [isOpen]);
85
+
86
+ const contentId = `collapsible-content-${Math.random().toString(36).substr(2, 9)}`;
87
+
88
+ return (
89
+ <div className={className} {...props}>
90
+ <button
91
+ type="button"
92
+ onClick={toggle}
93
+ disabled={disabled}
94
+ aria-expanded={isOpen}
95
+ aria-controls={contentId}
96
+ aria-disabled={disabled}
97
+ className="w-full text-left"
98
+ >
99
+ {trigger}
100
+ </button>
101
+ <div
102
+ id={contentId}
103
+ ref={contentRef}
104
+ style={{
105
+ height: typeof height === 'number' ? `${height}px` : height,
106
+ overflow: 'hidden',
107
+ transition: `height ${duration}ms ease-in-out`,
108
+ }}
109
+ aria-hidden={!isOpen}
110
+ >
111
+ <div>{children}</div>
112
+ </div>
113
+ </div>
114
+ );
115
+ }
@@ -52,4 +52,48 @@ export const WithIcon: StoryObj<typeof SidebarItem> = {
52
52
  },
53
53
  };
54
54
 
55
+ export const Nested: StoryObj<typeof SidebarItem> = {
56
+ args: {
57
+ href: "/epics",
58
+ children: "Epics",
59
+ isActive: false,
60
+ nested: true,
61
+ icon: (
62
+ <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
63
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
64
+ </svg>
65
+ ),
66
+ },
67
+ };
68
+
69
+ export const NestedLevel2: StoryObj<typeof SidebarItem> = {
70
+ args: {
71
+ href: "/epics",
72
+ children: "Epics",
73
+ isActive: false,
74
+ nested: 2,
75
+ icon: (
76
+ <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
77
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
78
+ </svg>
79
+ ),
80
+ },
81
+ };
82
+
83
+ export const DifferentIconSizes: StoryObj<typeof SidebarItem> = {
84
+ render: () => (
85
+ <div className="space-y-2 w-64">
86
+ <SidebarItem href="/test" iconSize="sm" icon={<span>📄</span>}>
87
+ Small Icon
88
+ </SidebarItem>
89
+ <SidebarItem href="/test" iconSize="md" icon={<span>📄</span>}>
90
+ Medium Icon (default)
91
+ </SidebarItem>
92
+ <SidebarItem href="/test" iconSize="lg" icon={<span>📄</span>}>
93
+ Large Icon
94
+ </SidebarItem>
95
+ </div>
96
+ ),
97
+ };
98
+
55
99
  export default meta;
@@ -22,4 +22,44 @@ describe("SidebarItem", () => {
22
22
  render(<SidebarItem href="/epics" icon={icon}>Epics</SidebarItem>);
23
23
  expect(screen.getByTestId("icon")).toBeInTheDocument();
24
24
  });
25
+
26
+ it("applies nested indent when nested is true", () => {
27
+ render(<SidebarItem href="/epics" nested={true}>Epics</SidebarItem>);
28
+ const link = screen.getByRole("link");
29
+ expect(link.className).toContain("pl-8");
30
+ });
31
+
32
+ it("applies nested indent for specific level", () => {
33
+ render(<SidebarItem href="/epics" nested={2}>Epics</SidebarItem>);
34
+ const link = screen.getByRole("link");
35
+ expect(link.className).toContain("pl-12");
36
+ });
37
+
38
+ it("uses default padding when not nested", () => {
39
+ render(<SidebarItem href="/epics">Epics</SidebarItem>);
40
+ const link = screen.getByRole("link");
41
+ expect(link.className).toContain("px-4");
42
+ expect(link.className).not.toContain("pl-8");
43
+ });
44
+
45
+ it("applies correct icon size classes", () => {
46
+ const icon = <span>Icon</span>;
47
+ const { rerender } = render(
48
+ <SidebarItem href="/test" icon={icon} iconSize="sm">Test</SidebarItem>
49
+ );
50
+ let iconSpan = screen.getByText("Icon").parentElement;
51
+ expect(iconSpan?.className).toContain("h-4 w-4");
52
+
53
+ rerender(
54
+ <SidebarItem href="/test" icon={icon} iconSize="md">Test</SidebarItem>
55
+ );
56
+ iconSpan = screen.getByText("Icon").parentElement;
57
+ expect(iconSpan?.className).toContain("h-5 w-5");
58
+
59
+ rerender(
60
+ <SidebarItem href="/test" icon={icon} iconSize="lg">Test</SidebarItem>
61
+ );
62
+ iconSpan = screen.getByText("Icon").parentElement;
63
+ expect(iconSpan?.className).toContain("h-6 w-6");
64
+ });
25
65
  });
@@ -1,11 +1,14 @@
1
1
  'use client';
2
2
 
3
3
  import type { AnchorHTMLAttributes, ReactNode } from "react";
4
+ import { SIDEBAR_TOKENS, getNestedIndentClass } from "../../tokens/sidebar";
4
5
 
5
6
  export interface SidebarItemProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> {
6
7
  href: string;
7
8
  isActive?: boolean;
8
9
  icon?: ReactNode;
10
+ nested?: boolean | number; // true = level 1, number = specific level
11
+ iconSize?: 'sm' | 'md' | 'lg'; // Default: 'md'
9
12
  children: ReactNode;
10
13
  }
11
14
 
@@ -26,25 +29,38 @@ export default function SidebarItem({
26
29
  href,
27
30
  isActive = false,
28
31
  icon,
32
+ nested = false,
33
+ iconSize = 'md',
29
34
  children,
30
35
  className = "",
31
36
  ...props
32
37
  }: SidebarItemProps) {
38
+ // Calculate nested level
39
+ const nestedLevel = typeof nested === 'number' ? nested : (nested ? 1 : 0);
40
+
41
+ // Get indent class based on nested level
42
+ const indentClass = getNestedIndentClass(nestedLevel);
43
+
44
+ // Base classes using tokens
33
45
  const baseClasses = [
34
46
  "flex",
35
47
  "items-center",
36
- "px-4",
37
- "py-2",
38
- "text-sm",
48
+ indentClass,
49
+ SIDEBAR_TOKENS.spacing.itemPaddingY,
50
+ SIDEBAR_TOKENS.text.sm,
39
51
  "font-medium",
40
52
  "rounded-md",
41
53
  "transition-colors",
42
54
  "hover:bg-gray-100",
43
55
  ];
44
56
 
57
+ // Active classes using tokens
45
58
  const activeClasses = isActive
46
- ? "bg-indigo-50 text-indigo-700 border-r-2 border-indigo-600"
47
- : "text-gray-700 hover:text-gray-900";
59
+ ? `${SIDEBAR_TOKENS.colors.active.bg} ${SIDEBAR_TOKENS.colors.active.text} border-r-2 ${SIDEBAR_TOKENS.colors.active.border}`
60
+ : `${SIDEBAR_TOKENS.colors.inactive.text} ${SIDEBAR_TOKENS.colors.inactive.hover}`;
61
+
62
+ // Icon size class from tokens
63
+ const iconSizeClass = SIDEBAR_TOKENS.icon[iconSize];
48
64
 
49
65
  const classes = [
50
66
  ...baseClasses,
@@ -54,7 +70,11 @@ export default function SidebarItem({
54
70
 
55
71
  return (
56
72
  <a href={href} className={classes} {...props}>
57
- {icon && <span className="mr-3 flex-shrink-0">{icon}</span>}
73
+ {icon && (
74
+ <span className={`${iconSizeClass} ${SIDEBAR_TOKENS.spacing.iconMargin} shrink-0`}>
75
+ {icon}
76
+ </span>
77
+ )}
58
78
  <span>{children}</span>
59
79
  </a>
60
80
  );
@@ -28,3 +28,6 @@ export type { SkeletonProps } from "./Skeleton/Skeleton";
28
28
 
29
29
  export { default as SidebarItem } from "./SidebarItem/SidebarItem";
30
30
  export type { SidebarItemProps } from "./SidebarItem/SidebarItem";
31
+
32
+ export { default as Collapsible } from "./Collapsible/Collapsible";
33
+ export type { CollapsibleProps } from "./Collapsible/Collapsible";
@@ -0,0 +1,83 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback } from 'react';
4
+
5
+ export interface UseCollapsibleOptions {
6
+ defaultOpen?: boolean;
7
+ open?: boolean; // Controlled mode
8
+ onOpenChange?: (open: boolean) => void;
9
+ storageKey?: string; // For localStorage persistence
10
+ }
11
+
12
+ export interface UseCollapsibleReturn {
13
+ isOpen: boolean;
14
+ toggle: () => void;
15
+ setOpen: (open: boolean) => void;
16
+ }
17
+
18
+ /**
19
+ * useCollapsible Hook
20
+ *
21
+ * Reusable hook for collapsible component logic.
22
+ * Supports both controlled and uncontrolled modes.
23
+ * Optional localStorage persistence.
24
+ *
25
+ * @example
26
+ * ```tsx
27
+ * const { isOpen, toggle } = useCollapsible({
28
+ * defaultOpen: true,
29
+ * storageKey: 'my-collapsible-state'
30
+ * });
31
+ * ```
32
+ */
33
+ export function useCollapsible({
34
+ defaultOpen = true,
35
+ open,
36
+ onOpenChange,
37
+ storageKey,
38
+ }: UseCollapsibleOptions): UseCollapsibleReturn {
39
+ // Load initial state from localStorage if storageKey is provided
40
+ const getInitialState = useCallback((): boolean => {
41
+ if (storageKey && typeof window !== 'undefined') {
42
+ const stored = localStorage.getItem(storageKey);
43
+ if (stored !== null) {
44
+ return stored === 'true';
45
+ }
46
+ }
47
+ return defaultOpen;
48
+ }, [defaultOpen, storageKey]);
49
+
50
+ const [internalOpen, setInternalOpen] = useState<boolean>(getInitialState);
51
+
52
+ // Use controlled state if provided, otherwise use internal state
53
+ const isOpen = open !== undefined ? open : internalOpen;
54
+
55
+ // Persist to localStorage when state changes
56
+ useEffect(() => {
57
+ if (storageKey && typeof window !== 'undefined' && open === undefined) {
58
+ localStorage.setItem(storageKey, String(internalOpen));
59
+ }
60
+ }, [internalOpen, storageKey, open]);
61
+
62
+ const setOpen = useCallback(
63
+ (newOpen: boolean) => {
64
+ if (open === undefined) {
65
+ // Uncontrolled mode
66
+ setInternalOpen(newOpen);
67
+ }
68
+ // In controlled mode, parent handles state
69
+ onOpenChange?.(newOpen);
70
+ },
71
+ [open, onOpenChange]
72
+ );
73
+
74
+ const toggle = useCallback(() => {
75
+ setOpen(!isOpen);
76
+ }, [isOpen, setOpen]);
77
+
78
+ return {
79
+ isOpen,
80
+ toggle,
81
+ setOpen,
82
+ };
83
+ }
package/src/ui/index.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./atoms";
2
2
  export * from "./molecules";
3
3
  export * from "./organisms";
4
+ export * from "./tokens/sidebar";
@@ -0,0 +1,173 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import SidebarGroup from "./SidebarGroup";
4
+ import SidebarItem from "../../atoms/SidebarItem/SidebarItem";
5
+
6
+ const meta: Meta<typeof SidebarGroup> = {
7
+ title: "UI/Molecules/SidebarGroup",
8
+ component: SidebarGroup,
9
+ parameters: {
10
+ docs: {
11
+ description: {
12
+ component: "A group container for sidebar items with optional title. Supports collapsible groups.",
13
+ },
14
+ },
15
+ },
16
+ argTypes: {
17
+ title: {
18
+ control: "text",
19
+ description: "Title text for the group",
20
+ },
21
+ collapsible: {
22
+ control: "boolean",
23
+ description: "Whether the group can be collapsed",
24
+ },
25
+ defaultCollapsed: {
26
+ control: "boolean",
27
+ description: "Initial collapsed state (uncontrolled mode)",
28
+ },
29
+ },
30
+ };
31
+
32
+ export const Default: StoryObj<typeof SidebarGroup> = {
33
+ args: {
34
+ title: "Agile",
35
+ children: (
36
+ <>
37
+ <SidebarItem href="/epics">Epics</SidebarItem>
38
+ <SidebarItem href="/stories">Stories</SidebarItem>
39
+ <SidebarItem href="/tasks">Tasks</SidebarItem>
40
+ </>
41
+ ),
42
+ },
43
+ };
44
+
45
+ export const WithoutTitle: StoryObj<typeof SidebarGroup> = {
46
+ args: {
47
+ children: (
48
+ <>
49
+ <SidebarItem href="/kanban">Kanban</SidebarItem>
50
+ <SidebarItem href="/sprints">Sprints</SidebarItem>
51
+ </>
52
+ ),
53
+ },
54
+ };
55
+
56
+ export const Collapsible: StoryObj<typeof SidebarGroup> = {
57
+ args: {
58
+ title: "Backlog",
59
+ collapsible: true,
60
+ defaultCollapsed: false,
61
+ children: (
62
+ <>
63
+ <SidebarItem href="/epics" nested={true}>Epics</SidebarItem>
64
+ <SidebarItem href="/stories" nested={true}>Stories</SidebarItem>
65
+ <SidebarItem href="/tasks" nested={true}>Tasks</SidebarItem>
66
+ </>
67
+ ),
68
+ },
69
+ };
70
+
71
+ export const CollapsibleDefaultCollapsed: StoryObj<typeof SidebarGroup> = {
72
+ args: {
73
+ title: "Backlog",
74
+ collapsible: true,
75
+ defaultCollapsed: true,
76
+ children: (
77
+ <>
78
+ <SidebarItem href="/epics" nested={true}>Epics</SidebarItem>
79
+ <SidebarItem href="/stories" nested={true}>Stories</SidebarItem>
80
+ <SidebarItem href="/tasks" nested={true}>Tasks</SidebarItem>
81
+ </>
82
+ ),
83
+ },
84
+ };
85
+
86
+ export const ControlledCollapsible: StoryObj<typeof SidebarGroup> = {
87
+ render: () => {
88
+ const [collapsed, setCollapsed] = useState(false);
89
+ return (
90
+ <div className="space-y-4">
91
+ <button
92
+ onClick={() => setCollapsed(!collapsed)}
93
+ className="px-4 py-2 bg-gray-100 rounded"
94
+ >
95
+ {collapsed ? "Expand" : "Collapse"} (External Control)
96
+ </button>
97
+ <SidebarGroup
98
+ title="Backlog"
99
+ collapsible={true}
100
+ collapsed={collapsed}
101
+ onCollapseChange={setCollapsed}
102
+ >
103
+ <SidebarItem href="/epics" nested={true}>Epics</SidebarItem>
104
+ <SidebarItem href="/stories" nested={true}>Stories</SidebarItem>
105
+ <SidebarItem href="/tasks" nested={true}>Tasks</SidebarItem>
106
+ </SidebarGroup>
107
+ </div>
108
+ );
109
+ },
110
+ };
111
+
112
+ export const WithNestedItems: StoryObj<typeof SidebarGroup> = {
113
+ args: {
114
+ title: "Backlog",
115
+ collapsible: true,
116
+ defaultCollapsed: false,
117
+ children: (
118
+ <>
119
+ <SidebarItem
120
+ href="/epics"
121
+ nested={true}
122
+ icon={
123
+ <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
124
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
125
+ </svg>
126
+ }
127
+ >
128
+ Epics
129
+ </SidebarItem>
130
+ <SidebarItem
131
+ href="/stories"
132
+ nested={true}
133
+ icon={
134
+ <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
135
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
136
+ </svg>
137
+ }
138
+ >
139
+ Stories
140
+ </SidebarItem>
141
+ <SidebarItem
142
+ href="/tasks"
143
+ nested={true}
144
+ icon={
145
+ <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
146
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
147
+ </svg>
148
+ }
149
+ >
150
+ Tasks
151
+ </SidebarItem>
152
+ </>
153
+ ),
154
+ },
155
+ };
156
+
157
+ export const MultipleGroups: StoryObj<typeof SidebarGroup> = {
158
+ render: () => (
159
+ <div className="w-64 bg-white border-r border-gray-200 p-4 space-y-4">
160
+ <SidebarGroup title="Backlog" collapsible={true} defaultCollapsed={false}>
161
+ <SidebarItem href="/epics" nested={true}>Epics</SidebarItem>
162
+ <SidebarItem href="/stories" nested={true}>Stories</SidebarItem>
163
+ <SidebarItem href="/tasks" nested={true}>Tasks</SidebarItem>
164
+ </SidebarGroup>
165
+ <SidebarGroup>
166
+ <SidebarItem href="/kanban">Kanban</SidebarItem>
167
+ <SidebarItem href="/sprints">Sprints</SidebarItem>
168
+ </SidebarGroup>
169
+ </div>
170
+ ),
171
+ };
172
+
173
+ export default meta;
@@ -0,0 +1,131 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3
+ import SidebarGroup from './SidebarGroup';
4
+ import SidebarItem from '../../atoms/SidebarItem/SidebarItem';
5
+
6
+ describe('SidebarGroup', () => {
7
+ beforeEach(() => {
8
+ localStorage.clear();
9
+ });
10
+
11
+ afterEach(() => {
12
+ localStorage.clear();
13
+ });
14
+
15
+ it('renders title and children', () => {
16
+ render(
17
+ <SidebarGroup title="Test Group">
18
+ <SidebarItem href="/test">Test Item</SidebarItem>
19
+ </SidebarGroup>
20
+ );
21
+
22
+ expect(screen.getByText('Test Group')).toBeInTheDocument();
23
+ expect(screen.getByText('Test Item')).toBeInTheDocument();
24
+ });
25
+
26
+ it('renders without title', () => {
27
+ render(
28
+ <SidebarGroup>
29
+ <SidebarItem href="/test">Test Item</SidebarItem>
30
+ </SidebarGroup>
31
+ );
32
+
33
+ expect(screen.getByText('Test Item')).toBeInTheDocument();
34
+ });
35
+
36
+ it('collapses and expands when collapsible', async () => {
37
+ render(
38
+ <SidebarGroup title="Collapsible Group" collapsible={true} defaultCollapsed={false}>
39
+ <SidebarItem href="/test">Test Item</SidebarItem>
40
+ </SidebarGroup>
41
+ );
42
+
43
+ const title = screen.getByText('Collapsible Group').closest('button');
44
+ expect(title).toBeInTheDocument();
45
+ expect(screen.getByText('Test Item')).toBeInTheDocument();
46
+
47
+ fireEvent.click(title!);
48
+
49
+ await waitFor(() => {
50
+ const item = screen.queryByText('Test Item');
51
+ // Item should be hidden (aria-hidden="true")
52
+ expect(item?.parentElement?.parentElement).toHaveAttribute('aria-hidden', 'true');
53
+ });
54
+ });
55
+
56
+ it('starts collapsed when defaultCollapsed is true', () => {
57
+ render(
58
+ <SidebarGroup title="Collapsible Group" collapsible={true} defaultCollapsed={true}>
59
+ <SidebarItem href="/test">Test Item</SidebarItem>
60
+ </SidebarGroup>
61
+ );
62
+
63
+ const content = screen.getByText('Test Item').parentElement?.parentElement;
64
+ expect(content).toHaveAttribute('aria-hidden', 'true');
65
+ });
66
+
67
+ it('calls onCollapseChange when provided (controlled mode)', () => {
68
+ const handleCollapseChange = vi.fn();
69
+ render(
70
+ <SidebarGroup
71
+ title="Controlled Group"
72
+ collapsible={true}
73
+ collapsed={false}
74
+ onCollapseChange={handleCollapseChange}
75
+ >
76
+ <SidebarItem href="/test">Test Item</SidebarItem>
77
+ </SidebarGroup>
78
+ );
79
+
80
+ const title = screen.getByText('Controlled Group').closest('button');
81
+ fireEvent.click(title!);
82
+
83
+ expect(handleCollapseChange).toHaveBeenCalledWith(true);
84
+ });
85
+
86
+ it('persists state in localStorage when storageKey is provided', async () => {
87
+ const storageKey = 'test-sidebar-group';
88
+
89
+ render(
90
+ <SidebarGroup
91
+ title="Persistent Group"
92
+ collapsible={true}
93
+ defaultCollapsed={false}
94
+ storageKey={storageKey}
95
+ >
96
+ <SidebarItem href="/test">Test Item</SidebarItem>
97
+ </SidebarGroup>
98
+ );
99
+
100
+ const title = screen.getByText('Persistent Group').closest('button');
101
+ fireEvent.click(title!);
102
+
103
+ await waitFor(() => {
104
+ expect(localStorage.getItem(storageKey)).toBe('false');
105
+ });
106
+ });
107
+
108
+ it('shows chevron icon when collapsible and showChevron is true', () => {
109
+ render(
110
+ <SidebarGroup title="Group" collapsible={true} showChevron={true}>
111
+ <SidebarItem href="/test">Test Item</SidebarItem>
112
+ </SidebarGroup>
113
+ );
114
+
115
+ const title = screen.getByText('Group').closest('button');
116
+ const svg = title?.querySelector('svg');
117
+ expect(svg).toBeInTheDocument();
118
+ });
119
+
120
+ it('hides chevron icon when showChevron is false', () => {
121
+ render(
122
+ <SidebarGroup title="Group" collapsible={true} showChevron={false}>
123
+ <SidebarItem href="/test">Test Item</SidebarItem>
124
+ </SidebarGroup>
125
+ );
126
+
127
+ const title = screen.getByText('Group').closest('button');
128
+ const svg = title?.querySelector('svg');
129
+ expect(svg).not.toBeInTheDocument();
130
+ });
131
+ });
@@ -2,10 +2,18 @@
2
2
 
3
3
  import type { HTMLAttributes, ReactNode } from "react";
4
4
  import { Text } from "../../atoms";
5
+ import Collapsible from "../../atoms/Collapsible/Collapsible";
6
+ import { SIDEBAR_TOKENS } from "../../tokens/sidebar";
5
7
 
6
8
  export interface SidebarGroupProps extends HTMLAttributes<HTMLDivElement> {
7
9
  title?: string;
8
10
  children: ReactNode;
11
+ collapsible?: boolean;
12
+ defaultCollapsed?: boolean;
13
+ collapsed?: boolean; // Controlled mode
14
+ onCollapseChange?: (collapsed: boolean) => void;
15
+ storageKey?: string; // For localStorage persistence
16
+ showChevron?: boolean; // Default: true when collapsible
9
17
  }
10
18
 
11
19
  /**
@@ -25,28 +33,95 @@ export interface SidebarGroupProps extends HTMLAttributes<HTMLDivElement> {
25
33
  export default function SidebarGroup({
26
34
  title,
27
35
  children,
36
+ collapsible = false,
37
+ defaultCollapsed = false,
38
+ collapsed,
39
+ onCollapseChange,
40
+ storageKey,
41
+ showChevron = true,
28
42
  className = "",
29
43
  ...props
30
44
  }: SidebarGroupProps) {
31
- const baseClasses = [
32
- "space-y-1",
33
- ];
45
+ const baseClasses = ["space-y-1"];
46
+ const classes = [...baseClasses, className].filter(Boolean).join(" ");
34
47
 
35
- const classes = [
36
- ...baseClasses,
37
- className,
38
- ].filter(Boolean).join(" ");
48
+ // Chevron icon component
49
+ const ChevronIcon = ({ isOpen }: { isOpen: boolean }) => (
50
+ <svg
51
+ className={`h-4 w-4 transition-transform duration-200 ${
52
+ isOpen ? 'rotate-90' : ''
53
+ }`}
54
+ fill="none"
55
+ viewBox="0 0 24 24"
56
+ stroke="currentColor"
57
+ >
58
+ <path
59
+ strokeLinecap="round"
60
+ strokeLinejoin="round"
61
+ strokeWidth={2}
62
+ d="M9 5l7 7-7 7"
63
+ />
64
+ </svg>
65
+ );
66
+
67
+ // Title content with optional chevron
68
+ const titleContent = (
69
+ <>
70
+ <Text
71
+ as="h3"
72
+ className={`${SIDEBAR_TOKENS.text.xs} font-semibold ${SIDEBAR_TOKENS.colors.groupTitle} uppercase tracking-wider`}
73
+ >
74
+ {title}
75
+ </Text>
76
+ {collapsible && showChevron && (
77
+ <ChevronIcon isOpen={collapsed !== undefined ? !collapsed : !defaultCollapsed} />
78
+ )}
79
+ </>
80
+ );
81
+
82
+ // If collapsible and has title, use Collapsible component
83
+ if (collapsible && title) {
84
+ return (
85
+ <Collapsible
86
+ defaultOpen={!defaultCollapsed}
87
+ open={collapsed !== undefined ? !collapsed : undefined}
88
+ onOpenChange={(open) => onCollapseChange?.(!open)}
89
+ storageKey={storageKey}
90
+ trigger={
91
+ <div className={`${SIDEBAR_TOKENS.spacing.groupTitlePadding} flex items-center justify-between w-full`}>
92
+ <Text
93
+ as="h3"
94
+ className={`${SIDEBAR_TOKENS.text.xs} font-semibold ${SIDEBAR_TOKENS.colors.groupTitle} uppercase tracking-wider`}
95
+ >
96
+ {title}
97
+ </Text>
98
+ {showChevron && (
99
+ <ChevronIcon isOpen={collapsed !== undefined ? !collapsed : !defaultCollapsed} />
100
+ )}
101
+ </div>
102
+ }
103
+ className={classes}
104
+ {...props}
105
+ >
106
+ <div className="space-y-1">{children}</div>
107
+ </Collapsible>
108
+ );
109
+ }
39
110
 
111
+ // Non-collapsible group (default behavior)
40
112
  return (
41
113
  <div className={classes} {...props}>
42
114
  {title && (
43
- <Text as="h3" className="px-4 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wider">
44
- {title}
45
- </Text>
115
+ <div className={SIDEBAR_TOKENS.spacing.groupTitlePadding}>
116
+ <Text
117
+ as="h3"
118
+ className={`${SIDEBAR_TOKENS.text.xs} font-semibold ${SIDEBAR_TOKENS.colors.groupTitle} uppercase tracking-wider`}
119
+ >
120
+ {title}
121
+ </Text>
122
+ </div>
46
123
  )}
47
- <div className="space-y-1">
48
- {children}
49
- </div>
124
+ <div className="space-y-1">{children}</div>
50
125
  </div>
51
126
  );
52
127
  }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Sidebar Design Tokens
3
+ *
4
+ * Centralized tokens for sidebar components to ensure consistency
5
+ * and ease of maintenance. All spacing, sizing, and color values
6
+ * should reference these tokens.
7
+ */
8
+
9
+ export const SIDEBAR_TOKENS = {
10
+ // Icon sizes
11
+ icon: {
12
+ sm: 'h-4 w-4', // 16px
13
+ md: 'h-5 w-5', // 20px (default)
14
+ lg: 'h-6 w-6', // 24px
15
+ },
16
+
17
+ // Text sizes
18
+ text: {
19
+ xs: 'text-xs', // 12px (group titles)
20
+ sm: 'text-sm', // 14px (items - default)
21
+ base: 'text-base', // 16px
22
+ },
23
+
24
+ // Spacing
25
+ spacing: {
26
+ itemPaddingX: 'px-4', // 16px horizontal padding for items
27
+ itemPaddingY: 'py-2', // 8px vertical padding for items
28
+ nestedIndent: 'pl-8', // 32px for nested items (level 1)
29
+ nestedIndentLevel2: 'pl-12', // 48px for nested items (level 2)
30
+ nestedIndentLevel3: 'pl-16', // 64px for nested items (level 3)
31
+ groupTitlePadding: 'px-4 py-2', // Padding for group titles
32
+ iconMargin: 'mr-3', // 12px margin between icon and text
33
+ },
34
+
35
+ // Colors (using Tailwind classes)
36
+ colors: {
37
+ active: {
38
+ bg: 'bg-indigo-50',
39
+ text: 'text-indigo-700',
40
+ border: 'border-indigo-600',
41
+ },
42
+ inactive: {
43
+ text: 'text-gray-700',
44
+ hover: 'hover:bg-gray-100 hover:text-gray-900',
45
+ },
46
+ groupTitle: 'text-gray-500',
47
+ },
48
+ } as const;
49
+
50
+ /**
51
+ * Helper function to get nested indent class based on level
52
+ */
53
+ export function getNestedIndentClass(level: number): string {
54
+ if (level <= 0) return SIDEBAR_TOKENS.spacing.itemPaddingX;
55
+ if (level === 1) return SIDEBAR_TOKENS.spacing.nestedIndent;
56
+ if (level === 2) return SIDEBAR_TOKENS.spacing.nestedIndentLevel2;
57
+ if (level === 3) return SIDEBAR_TOKENS.spacing.nestedIndentLevel3;
58
+ // For levels > 3, calculate dynamically: pl-{4 + level * 4}
59
+ return `pl-${4 + level * 4}`;
60
+ }