@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.
Files changed (66) hide show
  1. package/dist/index.cjs +29 -4
  2. package/dist/index.js +1866 -716
  3. package/dist/ui/atoms/Button/Button.d.ts +28 -5
  4. package/dist/ui/atoms/Button/Button.stories.d.ts +11 -3
  5. package/dist/ui/atoms/Checkbox/Checkbox.d.ts +24 -0
  6. package/dist/ui/atoms/Checkbox/Checkbox.stories.d.ts +10 -0
  7. package/dist/ui/atoms/Checkbox/Checkbox.test.d.ts +1 -0
  8. package/dist/ui/atoms/Collapsible/Collapsible.d.ts +29 -0
  9. package/dist/ui/atoms/Collapsible/Collapsible.stories.d.ts +9 -0
  10. package/dist/ui/atoms/Collapsible/Collapsible.test.d.ts +1 -0
  11. package/dist/ui/atoms/Input/Input.d.ts +28 -4
  12. package/dist/ui/atoms/Input/Input.stories.d.ts +8 -3
  13. package/dist/ui/atoms/Radio/Radio.d.ts +26 -0
  14. package/dist/ui/atoms/Radio/Radio.stories.d.ts +10 -0
  15. package/dist/ui/atoms/Radio/Radio.test.d.ts +1 -0
  16. package/dist/ui/atoms/SidebarItem/SidebarItem.d.ts +3 -1
  17. package/dist/ui/atoms/SidebarItem/SidebarItem.stories.d.ts +3 -0
  18. package/dist/ui/atoms/index.d.ts +7 -0
  19. package/dist/ui/hooks/useCollapsible.d.ts +27 -0
  20. package/dist/ui/index.d.ts +13 -0
  21. package/dist/ui/molecules/InputWithLabel/InputWithLabel.d.ts +4 -2
  22. package/dist/ui/molecules/SidebarGroup/SidebarGroup.d.ts +8 -1
  23. package/dist/ui/molecules/SidebarGroup/SidebarGroup.stories.d.ts +11 -0
  24. package/dist/ui/molecules/SidebarGroup/SidebarGroup.test.d.ts +1 -0
  25. package/dist/ui/providers/ThemeProvider.d.ts +34 -0
  26. package/dist/ui/tokens/breakpoints.d.ts +36 -0
  27. package/dist/ui/tokens/colors.d.ts +89 -0
  28. package/dist/ui/tokens/sidebar.d.ts +48 -0
  29. package/dist/ui/tokens/spacing.d.ts +53 -0
  30. package/dist/ui/tokens/themes/dark.d.ts +38 -0
  31. package/dist/ui/tokens/themes/light.d.ts +38 -0
  32. package/dist/ui/tokens/tokens.factory.d.ts +57 -0
  33. package/dist/ui/tokens/typography.d.ts +90 -0
  34. package/package.json +3 -2
  35. package/src/ui/atoms/Button/Button.stories.tsx +77 -7
  36. package/src/ui/atoms/Button/Button.tsx +176 -28
  37. package/src/ui/atoms/Checkbox/Checkbox.stories.tsx +61 -0
  38. package/src/ui/atoms/Checkbox/Checkbox.test.tsx +32 -0
  39. package/src/ui/atoms/Checkbox/Checkbox.tsx +103 -0
  40. package/src/ui/atoms/Collapsible/Collapsible.stories.tsx +124 -0
  41. package/src/ui/atoms/Collapsible/Collapsible.test.tsx +174 -0
  42. package/src/ui/atoms/Collapsible/Collapsible.tsx +115 -0
  43. package/src/ui/atoms/Input/Input.stories.tsx +67 -6
  44. package/src/ui/atoms/Input/Input.tsx +117 -14
  45. package/src/ui/atoms/Radio/Radio.stories.tsx +72 -0
  46. package/src/ui/atoms/Radio/Radio.test.tsx +32 -0
  47. package/src/ui/atoms/Radio/Radio.tsx +104 -0
  48. package/src/ui/atoms/SidebarItem/SidebarItem.stories.tsx +44 -0
  49. package/src/ui/atoms/SidebarItem/SidebarItem.test.tsx +40 -0
  50. package/src/ui/atoms/SidebarItem/SidebarItem.tsx +26 -6
  51. package/src/ui/atoms/index.ts +10 -0
  52. package/src/ui/hooks/useCollapsible.ts +83 -0
  53. package/src/ui/index.ts +15 -0
  54. package/src/ui/molecules/InputWithLabel/InputWithLabel.tsx +5 -4
  55. package/src/ui/molecules/SidebarGroup/SidebarGroup.stories.tsx +173 -0
  56. package/src/ui/molecules/SidebarGroup/SidebarGroup.test.tsx +131 -0
  57. package/src/ui/molecules/SidebarGroup/SidebarGroup.tsx +80 -13
  58. package/src/ui/providers/ThemeProvider.tsx +105 -0
  59. package/src/ui/tokens/breakpoints.ts +71 -0
  60. package/src/ui/tokens/colors.ts +250 -0
  61. package/src/ui/tokens/sidebar.ts +66 -0
  62. package/src/ui/tokens/spacing.ts +127 -0
  63. package/src/ui/tokens/themes/dark.ts +18 -0
  64. package/src/ui/tokens/themes/light.ts +18 -0
  65. package/src/ui/tokens/tokens.factory.ts +117 -0
  66. 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
- "space-y-1",
33
- ];
48
+ const baseClasses = ["space-y-1"];
49
+ const classes = [...baseClasses, className].filter(Boolean).join(" ");
34
50
 
35
- const classes = [
36
- ...baseClasses,
37
- className,
38
- ].filter(Boolean).join(" ");
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
- <Text as="h3" className="px-4 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wider">
44
- {title}
45
- </Text>
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
+ }