@fabio.caffarello/react-design-system 1.5.2 → 1.7.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/dist/index.cjs +29 -4
- package/dist/index.js +1866 -716
- package/dist/ui/atoms/Button/Button.d.ts +28 -5
- package/dist/ui/atoms/Button/Button.stories.d.ts +11 -3
- package/dist/ui/atoms/Checkbox/Checkbox.d.ts +24 -0
- package/dist/ui/atoms/Checkbox/Checkbox.stories.d.ts +10 -0
- package/dist/ui/atoms/Checkbox/Checkbox.test.d.ts +1 -0
- package/dist/ui/atoms/Collapsible/Collapsible.d.ts +29 -0
- package/dist/ui/atoms/Collapsible/Collapsible.stories.d.ts +9 -0
- package/dist/ui/atoms/Collapsible/Collapsible.test.d.ts +1 -0
- package/dist/ui/atoms/Input/Input.d.ts +28 -4
- package/dist/ui/atoms/Input/Input.stories.d.ts +8 -3
- package/dist/ui/atoms/Radio/Radio.d.ts +26 -0
- package/dist/ui/atoms/Radio/Radio.stories.d.ts +10 -0
- package/dist/ui/atoms/Radio/Radio.test.d.ts +1 -0
- package/dist/ui/atoms/SidebarItem/SidebarItem.d.ts +3 -1
- package/dist/ui/atoms/SidebarItem/SidebarItem.stories.d.ts +3 -0
- package/dist/ui/atoms/index.d.ts +7 -0
- package/dist/ui/hooks/useCollapsible.d.ts +27 -0
- package/dist/ui/index.d.ts +13 -0
- package/dist/ui/molecules/InputWithLabel/InputWithLabel.d.ts +4 -2
- package/dist/ui/molecules/SidebarGroup/SidebarGroup.d.ts +8 -1
- package/dist/ui/molecules/SidebarGroup/SidebarGroup.stories.d.ts +11 -0
- package/dist/ui/molecules/SidebarGroup/SidebarGroup.test.d.ts +1 -0
- package/dist/ui/providers/ThemeProvider.d.ts +34 -0
- package/dist/ui/tokens/breakpoints.d.ts +36 -0
- package/dist/ui/tokens/colors.d.ts +89 -0
- package/dist/ui/tokens/sidebar.d.ts +48 -0
- package/dist/ui/tokens/spacing.d.ts +53 -0
- package/dist/ui/tokens/themes/dark.d.ts +38 -0
- package/dist/ui/tokens/themes/light.d.ts +38 -0
- package/dist/ui/tokens/tokens.factory.d.ts +57 -0
- package/dist/ui/tokens/typography.d.ts +90 -0
- package/package.json +3 -2
- package/src/ui/atoms/Button/Button.stories.tsx +77 -7
- package/src/ui/atoms/Button/Button.tsx +176 -28
- package/src/ui/atoms/Checkbox/Checkbox.stories.tsx +61 -0
- package/src/ui/atoms/Checkbox/Checkbox.test.tsx +32 -0
- package/src/ui/atoms/Checkbox/Checkbox.tsx +103 -0
- 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/Input/Input.stories.tsx +67 -6
- package/src/ui/atoms/Input/Input.tsx +117 -14
- package/src/ui/atoms/Radio/Radio.stories.tsx +72 -0
- package/src/ui/atoms/Radio/Radio.test.tsx +32 -0
- package/src/ui/atoms/Radio/Radio.tsx +104 -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 +10 -0
- package/src/ui/hooks/useCollapsible.ts +83 -0
- package/src/ui/index.ts +15 -0
- package/src/ui/molecules/InputWithLabel/InputWithLabel.tsx +5 -4
- 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 +80 -13
- package/src/ui/providers/ThemeProvider.tsx +105 -0
- package/src/ui/tokens/breakpoints.ts +71 -0
- package/src/ui/tokens/colors.ts +250 -0
- package/src/ui/tokens/sidebar.ts +66 -0
- package/src/ui/tokens/spacing.ts +127 -0
- package/src/ui/tokens/themes/dark.ts +18 -0
- package/src/ui/tokens/themes/light.ts +18 -0
- package/src/ui/tokens/tokens.factory.ts +117 -0
- package/src/ui/tokens/typography.ts +191 -0
|
@@ -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
|
+
});
|
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import type { HTMLAttributes, ReactNode } from "react";
|
|
4
|
+
import { ChevronRight } from "lucide-react";
|
|
4
5
|
import { Text } from "../../atoms";
|
|
6
|
+
import Collapsible from "../../atoms/Collapsible/Collapsible";
|
|
7
|
+
import { SIDEBAR_TOKENS } from "../../tokens/sidebar";
|
|
5
8
|
|
|
6
9
|
export interface SidebarGroupProps extends HTMLAttributes<HTMLDivElement> {
|
|
7
10
|
title?: string;
|
|
11
|
+
titleIcon?: ReactNode; // Optional icon for the title
|
|
8
12
|
children: ReactNode;
|
|
13
|
+
collapsible?: boolean;
|
|
14
|
+
defaultCollapsed?: boolean;
|
|
15
|
+
collapsed?: boolean; // Controlled mode
|
|
16
|
+
onCollapseChange?: (collapsed: boolean) => void;
|
|
17
|
+
storageKey?: string; // For localStorage persistence
|
|
18
|
+
showChevron?: boolean; // Default: true when collapsible
|
|
9
19
|
}
|
|
10
20
|
|
|
11
21
|
/**
|
|
@@ -24,29 +34,86 @@ export interface SidebarGroupProps extends HTMLAttributes<HTMLDivElement> {
|
|
|
24
34
|
*/
|
|
25
35
|
export default function SidebarGroup({
|
|
26
36
|
title,
|
|
37
|
+
titleIcon,
|
|
27
38
|
children,
|
|
39
|
+
collapsible = false,
|
|
40
|
+
defaultCollapsed = false,
|
|
41
|
+
collapsed,
|
|
42
|
+
onCollapseChange,
|
|
43
|
+
storageKey,
|
|
44
|
+
showChevron = true,
|
|
28
45
|
className = "",
|
|
29
46
|
...props
|
|
30
47
|
}: SidebarGroupProps) {
|
|
31
|
-
const baseClasses = [
|
|
32
|
-
|
|
33
|
-
];
|
|
48
|
+
const baseClasses = ["space-y-1"];
|
|
49
|
+
const classes = [...baseClasses, className].filter(Boolean).join(" ");
|
|
34
50
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
51
|
+
// Chevron icon component using lucide-react
|
|
52
|
+
const ChevronIcon = ({ isOpen }: { isOpen: boolean }) => (
|
|
53
|
+
<ChevronRight
|
|
54
|
+
className={`${SIDEBAR_TOKENS.chevron.size} ${SIDEBAR_TOKENS.chevron.color} transition-transform duration-200 ${
|
|
55
|
+
isOpen ? 'rotate-90' : ''
|
|
56
|
+
}`}
|
|
57
|
+
/>
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// If collapsible and has title, use Collapsible component
|
|
61
|
+
if (collapsible && title) {
|
|
62
|
+
return (
|
|
63
|
+
<Collapsible
|
|
64
|
+
defaultOpen={!defaultCollapsed}
|
|
65
|
+
open={collapsed !== undefined ? !collapsed : undefined}
|
|
66
|
+
onOpenChange={(open) => onCollapseChange?.(!open)}
|
|
67
|
+
storageKey={storageKey}
|
|
68
|
+
trigger={
|
|
69
|
+
<div className={`${SIDEBAR_TOKENS.spacing.groupTitlePadding} flex items-center justify-between w-full hover:bg-gray-50 rounded-md transition-colors cursor-pointer`}>
|
|
70
|
+
<div className="flex items-center gap-2">
|
|
71
|
+
{titleIcon && (
|
|
72
|
+
<span className={`${SIDEBAR_TOKENS.icon.md} ${SIDEBAR_TOKENS.colors.groupTitle}`}>
|
|
73
|
+
{titleIcon}
|
|
74
|
+
</span>
|
|
75
|
+
)}
|
|
76
|
+
<Text
|
|
77
|
+
as="h3"
|
|
78
|
+
className={`${SIDEBAR_TOKENS.text.xs} font-semibold ${SIDEBAR_TOKENS.colors.groupTitle} uppercase tracking-wider`}
|
|
79
|
+
>
|
|
80
|
+
{title}
|
|
81
|
+
</Text>
|
|
82
|
+
</div>
|
|
83
|
+
{showChevron && (
|
|
84
|
+
<span className="ml-2">
|
|
85
|
+
<ChevronIcon isOpen={collapsed !== undefined ? !collapsed : !defaultCollapsed} />
|
|
86
|
+
</span>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
}
|
|
90
|
+
className={classes}
|
|
91
|
+
{...props}
|
|
92
|
+
>
|
|
93
|
+
<div className="space-y-1">{children}</div>
|
|
94
|
+
</Collapsible>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
39
97
|
|
|
98
|
+
// Non-collapsible group (default behavior)
|
|
40
99
|
return (
|
|
41
100
|
<div className={classes} {...props}>
|
|
42
101
|
{title && (
|
|
43
|
-
<
|
|
44
|
-
{
|
|
45
|
-
|
|
102
|
+
<div className={`${SIDEBAR_TOKENS.spacing.groupTitlePadding} flex items-center gap-2`}>
|
|
103
|
+
{titleIcon && (
|
|
104
|
+
<span className={`${SIDEBAR_TOKENS.icon.md} ${SIDEBAR_TOKENS.colors.groupTitle}`}>
|
|
105
|
+
{titleIcon}
|
|
106
|
+
</span>
|
|
107
|
+
)}
|
|
108
|
+
<Text
|
|
109
|
+
as="h3"
|
|
110
|
+
className={`${SIDEBAR_TOKENS.text.xs} font-semibold ${SIDEBAR_TOKENS.colors.groupTitle} uppercase tracking-wider`}
|
|
111
|
+
>
|
|
112
|
+
{title}
|
|
113
|
+
</Text>
|
|
114
|
+
</div>
|
|
46
115
|
)}
|
|
47
|
-
<div className="space-y-1">
|
|
48
|
-
{children}
|
|
49
|
-
</div>
|
|
116
|
+
<div className="space-y-1">{children}</div>
|
|
50
117
|
</div>
|
|
51
118
|
);
|
|
52
119
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
|
4
|
+
import { COLOR_TOKENS_LIGHT, COLOR_TOKENS_DARK, type ColorRole, type SemanticColor } from '../tokens/colors';
|
|
5
|
+
import type { ThemeMode } from '../tokens/tokens.factory';
|
|
6
|
+
|
|
7
|
+
export interface ThemeContextValue {
|
|
8
|
+
theme: ThemeMode;
|
|
9
|
+
toggleTheme: () => void;
|
|
10
|
+
setTheme: (theme: ThemeMode) => void;
|
|
11
|
+
colors: Record<ColorRole, SemanticColor>;
|
|
12
|
+
isDark: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
|
16
|
+
|
|
17
|
+
export interface ThemeProviderProps {
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
defaultTheme?: ThemeMode;
|
|
20
|
+
storageKey?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* ThemeProvider Component
|
|
25
|
+
*
|
|
26
|
+
* Provides theme context to the application.
|
|
27
|
+
* Uses Strategy Pattern for different theme strategies (light, dark).
|
|
28
|
+
* Uses Observer Pattern to notify components about theme changes.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```tsx
|
|
32
|
+
* <ThemeProvider defaultTheme="light">
|
|
33
|
+
* <App />
|
|
34
|
+
* </ThemeProvider>
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export function ThemeProvider({
|
|
38
|
+
children,
|
|
39
|
+
defaultTheme = 'light',
|
|
40
|
+
storageKey = 'theme',
|
|
41
|
+
}: ThemeProviderProps) {
|
|
42
|
+
const [theme, setThemeState] = useState<ThemeMode>(() => {
|
|
43
|
+
if (typeof window === 'undefined') {
|
|
44
|
+
return defaultTheme;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const stored = localStorage.getItem(storageKey);
|
|
49
|
+
if (stored === 'light' || stored === 'dark') {
|
|
50
|
+
return stored;
|
|
51
|
+
}
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.warn('Failed to read theme from localStorage:', error);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return defaultTheme;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
try {
|
|
61
|
+
localStorage.setItem(storageKey, theme);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.warn('Failed to save theme to localStorage:', error);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Apply theme class to document root
|
|
67
|
+
document.documentElement.classList.remove('light', 'dark');
|
|
68
|
+
document.documentElement.classList.add(theme);
|
|
69
|
+
}, [theme, storageKey]);
|
|
70
|
+
|
|
71
|
+
const toggleTheme = () => {
|
|
72
|
+
setThemeState((prev) => (prev === 'light' ? 'dark' : 'light'));
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const setTheme = (newTheme: ThemeMode) => {
|
|
76
|
+
setThemeState(newTheme);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const colors = theme === 'light' ? COLOR_TOKENS_LIGHT : COLOR_TOKENS_DARK;
|
|
80
|
+
|
|
81
|
+
const value: ThemeContextValue = {
|
|
82
|
+
theme,
|
|
83
|
+
toggleTheme,
|
|
84
|
+
setTheme,
|
|
85
|
+
colors,
|
|
86
|
+
isDark: theme === 'dark',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<ThemeContext.Provider value={value}>
|
|
91
|
+
{children}
|
|
92
|
+
</ThemeContext.Provider>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Hook to use theme context
|
|
98
|
+
*/
|
|
99
|
+
export function useTheme(): ThemeContextValue {
|
|
100
|
+
const context = useContext(ThemeContext);
|
|
101
|
+
if (context === undefined) {
|
|
102
|
+
throw new Error('useTheme must be used within a ThemeProvider');
|
|
103
|
+
}
|
|
104
|
+
return context;
|
|
105
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Breakpoint Tokens
|
|
3
|
+
*
|
|
4
|
+
* Responsive breakpoints for consistent media queries.
|
|
5
|
+
* Uses Factory Pattern for type-safe breakpoint creation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type BreakpointName = 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
|
9
|
+
|
|
10
|
+
export interface BreakpointToken {
|
|
11
|
+
name: BreakpointName;
|
|
12
|
+
minWidth: number;
|
|
13
|
+
px: string;
|
|
14
|
+
rem: string;
|
|
15
|
+
tailwind: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Breakpoint Token Factory
|
|
20
|
+
* Creates breakpoint tokens with consistent values
|
|
21
|
+
*/
|
|
22
|
+
export class BreakpointTokenFactory {
|
|
23
|
+
/**
|
|
24
|
+
* Create breakpoint token
|
|
25
|
+
*/
|
|
26
|
+
static create(name: BreakpointName): BreakpointToken {
|
|
27
|
+
const breakpointMap: Record<BreakpointName, { minWidth: number; tailwind: string }> = {
|
|
28
|
+
sm: { minWidth: 640, tailwind: 'sm' },
|
|
29
|
+
md: { minWidth: 768, tailwind: 'md' },
|
|
30
|
+
lg: { minWidth: 1024, tailwind: 'lg' },
|
|
31
|
+
xl: { minWidth: 1280, tailwind: 'xl' },
|
|
32
|
+
'2xl': { minWidth: 1536, tailwind: '2xl' },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const config = breakpointMap[name];
|
|
36
|
+
return {
|
|
37
|
+
name,
|
|
38
|
+
minWidth: config.minWidth,
|
|
39
|
+
px: `${config.minWidth}px`,
|
|
40
|
+
rem: `${config.minWidth / 16}rem`,
|
|
41
|
+
tailwind: config.tailwind,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Pre-defined breakpoint tokens
|
|
48
|
+
*/
|
|
49
|
+
export const BREAKPOINT_TOKENS: Record<BreakpointName, BreakpointToken> = {
|
|
50
|
+
sm: BreakpointTokenFactory.create('sm'),
|
|
51
|
+
md: BreakpointTokenFactory.create('md'),
|
|
52
|
+
lg: BreakpointTokenFactory.create('lg'),
|
|
53
|
+
xl: BreakpointTokenFactory.create('xl'),
|
|
54
|
+
'2xl': BreakpointTokenFactory.create('2xl'),
|
|
55
|
+
} as const;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Helper function to get breakpoint token
|
|
59
|
+
*/
|
|
60
|
+
export function getBreakpoint(name: BreakpointName): BreakpointToken {
|
|
61
|
+
return BREAKPOINT_TOKENS[name];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Helper function to create media query string
|
|
66
|
+
*/
|
|
67
|
+
export function getMediaQuery(name: BreakpointName, direction: 'min' | 'max' = 'min'): string {
|
|
68
|
+
const breakpoint = BREAKPOINT_TOKENS[name];
|
|
69
|
+
const operator = direction === 'min' ? 'min-width' : 'max-width';
|
|
70
|
+
return `@media (${operator}: ${breakpoint.px})`;
|
|
71
|
+
}
|