Skip to content

Commit faace7d

Browse files
feat: defaultTheme prop for ThemeProvider
1 parent 3f7fc81 commit faace7d

File tree

6 files changed

+347
-89
lines changed

6 files changed

+347
-89
lines changed

src/ThemeProvider.tsx

Lines changed: 69 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,61 @@
11
import * as React from "react";
22

3-
type Theme = 'light' | 'dark' | 'system';
3+
export type ColorTheme = 'blue' | 'green' | 'purple' | 'amber';
4+
export type BaseTheme = 'light' | 'dark' | 'system';
5+
export type Theme = BaseTheme | ColorTheme;
6+
47
type ThemeContextType = {
58
theme: Theme;
9+
colorTheme: ColorTheme | null;
10+
baseTheme: BaseTheme;
611
resolvedTheme: 'light' | 'dark';
712
setTheme: (theme: Theme) => void;
13+
availableThemes: Theme[];
814
};
915

16+
export const availableThemes: Theme[] = ['light', 'dark', 'system', 'blue', 'green', 'purple', 'amber'];
17+
1018
const ThemeContext = React.createContext<ThemeContextType | undefined>(undefined);
1119

12-
export const ThemeProvider: React.FC<{children: React.ReactNode}> = ({ children }) => {
20+
const COLOR_THEMES: ColorTheme[] = ['blue', 'green', 'purple', 'amber'];
21+
const BASE_THEMES: BaseTheme[] = ['light', 'dark', 'system'];
22+
23+
export interface ThemeProviderProps {
24+
children: React.ReactNode;
25+
defaultTheme?: Theme;
26+
}
27+
28+
export const ThemeProvider: React.FC<ThemeProviderProps> = ({
29+
children,
30+
defaultTheme = 'system'
31+
}) => {
1332
const [theme, setThemeState] = React.useState<Theme>(() => {
14-
if (typeof window === 'undefined') return 'system';
15-
return (localStorage.getItem('theme') as Theme) || 'system';
33+
if (typeof window === 'undefined') return defaultTheme;
34+
return (localStorage.getItem('theme') as Theme) || defaultTheme;
1635
});
1736

1837
const [resolvedTheme, setResolvedTheme] = React.useState<'light' | 'dark'>(() => {
1938
if (typeof window === 'undefined') return 'light';
2039
if (theme === 'system') {
2140
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
2241
}
23-
return theme;
42+
return BASE_THEMES.includes(theme as BaseTheme) ? (theme as 'light' | 'dark') : 'light';
2443
});
2544

45+
const baseTheme = React.useMemo<BaseTheme>(() => {
46+
if (BASE_THEMES.includes(theme as BaseTheme)) {
47+
return theme as BaseTheme;
48+
}
49+
return 'light';
50+
}, [theme]);
51+
52+
const colorTheme = React.useMemo<ColorTheme | null>(() => {
53+
if (COLOR_THEMES.includes(theme as ColorTheme)) {
54+
return theme as ColorTheme;
55+
}
56+
return null;
57+
}, [theme]);
58+
2659
// Handle system preference changes
2760
React.useEffect(() => {
2861
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
@@ -31,45 +64,62 @@ export const ThemeProvider: React.FC<{children: React.ReactNode}> = ({ children
3164
if (theme === 'system') {
3265
const newTheme = mediaQuery.matches ? 'dark' : 'light';
3366
setResolvedTheme(newTheme);
34-
updateDocumentClass(newTheme);
67+
updateDocumentClass(newTheme, colorTheme);
3568
}
3669
};
3770

3871
mediaQuery.addEventListener('change', handleChange);
3972
return () => mediaQuery.removeEventListener('change', handleChange);
40-
}, [theme]);
73+
}, [theme, colorTheme]);
4174

4275
// Update the DOM and resolved theme when theme changes
4376
React.useEffect(() => {
4477
if (theme === 'system') {
4578
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
4679
setResolvedTheme(systemTheme);
47-
updateDocumentClass(systemTheme);
80+
updateDocumentClass(systemTheme, colorTheme);
81+
} else if (BASE_THEMES.includes(theme as BaseTheme)) {
82+
setResolvedTheme(theme as 'light' | 'dark');
83+
updateDocumentClass(theme as 'light' | 'dark', colorTheme);
4884
} else {
49-
setResolvedTheme(theme);
50-
updateDocumentClass(theme);
85+
// For color themes, we apply both the base theme (light) and the color theme
86+
updateDocumentClass('light', theme as ColorTheme);
5187
}
52-
}, [theme]);
88+
}, [theme, colorTheme]);
5389

54-
const updateDocumentClass = (resolvedTheme: string) => {
55-
if (resolvedTheme === 'dark') {
90+
const updateDocumentClass = (baseTheme: 'light' | 'dark', colorTheme: ColorTheme | null) => {
91+
// Handle dark/light mode
92+
if (baseTheme === 'dark') {
5693
document.documentElement.classList.add('dark');
5794
} else {
5895
document.documentElement.classList.remove('dark');
5996
}
97+
98+
// Remove any existing color theme classes
99+
COLOR_THEMES.forEach(ct => {
100+
document.documentElement.classList.remove(`theme-${ct}`);
101+
});
102+
103+
// Apply color theme if present
104+
if (colorTheme) {
105+
document.documentElement.classList.add(`theme-${colorTheme}`);
106+
}
60107
};
61108

62109
const setTheme = (newTheme: Theme) => {
63110
setThemeState(newTheme);
64-
if (newTheme === 'system') {
65-
localStorage.removeItem('theme');
66-
} else {
67-
localStorage.setItem('theme', newTheme);
68-
}
111+
localStorage.setItem('theme', newTheme);
69112
};
70113

71114
return (
72-
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
115+
<ThemeContext.Provider value={{
116+
theme,
117+
colorTheme,
118+
baseTheme,
119+
resolvedTheme,
120+
setTheme,
121+
availableThemes
122+
}}>
73123
{children}
74124
</ThemeContext.Provider>
75125
);

src/components/dropdown/Dropdown.tsx

Lines changed: 75 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ import { ZINDEX } from "../../utils/z-index";
66
export interface DropdownProps {
77
trigger: React.ReactNode;
88
children: React.ReactNode;
9-
align?: "left" | "right";
9+
align?: "left" | "right" | "end";
1010
direction?: "up" | "down";
1111
className?: string;
12+
open?: boolean;
13+
onOpenChange?: (open: boolean) => void;
1214
}
1315

1416
export const Dropdown: React.FC<DropdownProps> = ({
@@ -17,6 +19,8 @@ export const Dropdown: React.FC<DropdownProps> = ({
1719
align = "left",
1820
direction = "down",
1921
className,
22+
open: controlledOpen,
23+
onOpenChange,
2024
}) => {
2125
const buttonRef = React.useRef<HTMLDivElement>(null);
2226

@@ -30,54 +34,80 @@ export const Dropdown: React.FC<DropdownProps> = ({
3034
document.documentElement.style.setProperty('--by', `${window.innerHeight - rect.top + 8}px`);
3135
}, []);
3236

37+
const isControlled = controlledOpen !== undefined;
38+
3339
return (
34-
<Menu>
35-
{({ open }) => (
36-
<div className="relative inline-block text-left">
37-
<div ref={buttonRef}>
38-
<Menu.Button
39-
className="inline-flex cursor-pointer"
40-
onClick={() => updatePosition(true)}
41-
>
42-
{trigger}
43-
</Menu.Button>
44-
</div>
40+
<Menu as="div"
41+
open={isControlled ? controlledOpen : undefined}
42+
onChange={(open) => isControlled && onOpenChange?.(open)}
43+
>
44+
{({ open: menuOpen }) => {
45+
// Handle controlled state changes
46+
const isOpen = isControlled ? controlledOpen : menuOpen;
47+
React.useEffect(() => {
48+
if (isControlled && isOpen !== controlledOpen) {
49+
onOpenChange?.(isOpen);
50+
}
51+
}, [isOpen, controlledOpen, isControlled]);
4552

46-
<Portal>
47-
<Transition
48-
show={open}
49-
enter="transition duration-100 ease-out"
50-
enterFrom="transform scale-95 opacity-0"
51-
enterTo="transform scale-100 opacity-100"
52-
leave="transition duration-75 ease-out"
53-
leaveFrom="transform scale-100 opacity-100"
54-
leaveTo="transform scale-95 opacity-0"
55-
beforeEnter={() => updatePosition(true)}
56-
afterLeave={() => updatePosition(false)}
57-
>
58-
<Menu.Items
59-
className={cn(
60-
"fixed rounded-lg shadow-lg",
61-
"bg-white dark:bg-gray-800",
62-
"border border-gray-200 dark:border-gray-700",
63-
"focus:outline-none",
64-
"min-w-[8rem] py-1",
65-
"z-[100]",
66-
className
67-
)}
68-
style={{
69-
left: align === "left" ? "var(--x)" : "unset",
70-
right: align === "right" ? "var(--rx)" : "unset",
71-
top: direction === "down" ? "var(--y)" : "unset",
72-
bottom: direction === "up" ? "var(--by)" : "unset",
53+
return (
54+
<div className="relative inline-block text-left">
55+
<div ref={buttonRef}>
56+
<Menu.Button
57+
className="inline-flex cursor-pointer"
58+
onClick={() => {
59+
updatePosition(true);
60+
if (isControlled) {
61+
onOpenChange?.(!controlledOpen);
62+
}
7363
}}
7464
>
75-
{children}
76-
</Menu.Items>
77-
</Transition>
78-
</Portal>
79-
</div>
80-
)}
65+
{trigger}
66+
</Menu.Button>
67+
</div>
68+
69+
<Portal>
70+
<Transition
71+
show={isOpen}
72+
enter="transition duration-100 ease-out"
73+
enterFrom="transform scale-95 opacity-0"
74+
enterTo="transform scale-100 opacity-100"
75+
leave="transition duration-75 ease-out"
76+
leaveFrom="transform scale-100 opacity-100"
77+
leaveTo="transform scale-95 opacity-0"
78+
beforeEnter={() => updatePosition(true)}
79+
afterLeave={() => {
80+
updatePosition(false);
81+
if (isControlled) {
82+
onOpenChange?.(false);
83+
}
84+
}}
85+
>
86+
<Menu.Items
87+
className={cn(
88+
"fixed rounded-lg shadow-lg",
89+
"bg-white dark:bg-gray-800",
90+
"border border-gray-200 dark:border-gray-700",
91+
"focus:outline-none",
92+
"min-w-[8rem] py-1",
93+
"z-[100]",
94+
className
95+
)}
96+
style={{
97+
left: align === "left" ? "var(--x)" : align === "end" ? "unset" : "unset",
98+
right: align === "right" ? "var(--rx)" : align === "end" ? "16px" : "unset",
99+
top: direction === "down" ? "var(--y)" : "unset",
100+
bottom: direction === "up" ? "var(--by)" : "unset",
101+
}}
102+
static
103+
>
104+
{children}
105+
</Menu.Items>
106+
</Transition>
107+
</Portal>
108+
</div>
109+
);
110+
}}
81111
</Menu>
82112
);
83113
};

src/docs/App.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,10 @@ const AppContent = () => {
129129
}
130130
};
131131

132+
const { theme, colorTheme } = useTheme();
133+
132134
return (
133-
<div className="min-h-screen bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
135+
<div className="min-h-screen">
134136
<Toaster
135137
position="bottom-right"
136138
toastOptions={{
@@ -150,7 +152,7 @@ const AppContent = () => {
150152
/>
151153
<Header>
152154
<HeaderContent>
153-
<Title level={3} className="text-blue-600 dark:text-blue-400">AV1-C</Title>
155+
<Title level={3} className={colorTheme ? `text-${colorTheme}-primary` : 'text-blue-600 dark:text-blue-400'}>AV1-C</Title>
154156
<div className="ml-auto flex items-center gap-4">
155157
<ThemeToggle />
156158
<Button
@@ -187,7 +189,7 @@ const AppContent = () => {
187189
// Main App component with ThemeProvider
188190
export const App = () => {
189191
return (
190-
<ThemeProvider>
192+
<ThemeProvider defaultTheme="light">
191193
<AppContent />
192194
</ThemeProvider>
193195
);

0 commit comments

Comments
 (0)