@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 +1 -1
- package/src/ui/atoms/Collapsible/Collapsible.stories.tsx +124 -0
- package/src/ui/atoms/Collapsible/Collapsible.test.tsx +174 -0
- package/src/ui/atoms/Collapsible/Collapsible.tsx +115 -0
- package/src/ui/atoms/SidebarItem/SidebarItem.stories.tsx +44 -0
- package/src/ui/atoms/SidebarItem/SidebarItem.test.tsx +40 -0
- package/src/ui/atoms/SidebarItem/SidebarItem.tsx +26 -6
- package/src/ui/atoms/index.ts +3 -0
- package/src/ui/hooks/useCollapsible.ts +83 -0
- package/src/ui/index.ts +1 -0
- package/src/ui/molecules/SidebarGroup/SidebarGroup.stories.tsx +173 -0
- package/src/ui/molecules/SidebarGroup/SidebarGroup.test.tsx +131 -0
- package/src/ui/molecules/SidebarGroup/SidebarGroup.tsx +88 -13
- package/src/ui/tokens/sidebar.ts +60 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
?
|
|
47
|
-
:
|
|
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 &&
|
|
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
|
);
|
package/src/ui/atoms/index.ts
CHANGED
|
@@ -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
|
@@ -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
|
-
|
|
33
|
-
];
|
|
45
|
+
const baseClasses = ["space-y-1"];
|
|
46
|
+
const classes = [...baseClasses, className].filter(Boolean).join(" ");
|
|
34
47
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
<
|
|
44
|
-
|
|
45
|
-
|
|
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
|
+
}
|