10 changed files with 89 additions and 150 deletions
@ -1,39 +1,44 @@ |
|||||
import { useTheme } from "@app/core/hooks/useTheme"; |
import { useTheme } from "@app/core/hooks/useTheme"; |
||||
import { Moon, Sun } from "lucide-react"; |
import { cn } from "@app/core/utils/cn"; |
||||
import React from "react"; |
import { Monitor, Moon, Sun } from "lucide-react"; |
||||
|
|
||||
type Theme = "light" | "dark"; |
type ThemePreference = "light" | "dark" | "system"; |
||||
|
|
||||
export default function ThemeSwitcher({ |
export default function ThemeSwitcher({ |
||||
className = "", |
className = "", |
||||
}: { className?: string }) { |
}: { |
||||
const currentTheme = useTheme(); // Get current theme from DOM
|
className?: string; |
||||
const [theme, setTheme] = React.useState<Theme>(currentTheme); |
}) { |
||||
|
const { theme, preference, setPreference } = useTheme(); |
||||
React.useEffect(() => { |
|
||||
document.documentElement.setAttribute("data-theme", theme); |
|
||||
localStorage.setItem("theme", theme); |
|
||||
}, [theme]); |
|
||||
|
|
||||
const themeIcons = { |
const themeIcons = { |
||||
light: ( |
light: <Sun className="size-5" />, |
||||
<Sun className="size-5 transition-transform duration-300 scale-100" /> |
dark: <Moon className="size-5" />, |
||||
), |
system: <Monitor className="size-5" />, |
||||
dark: ( |
|
||||
<Moon className="size-5 transition-transform duration-300 scale-100" /> |
|
||||
), |
|
||||
}; |
}; |
||||
|
|
||||
const toggleTheme = () => setTheme(theme === "light" ? "dark" : "light"); |
const toggleTheme = () => { |
||||
|
const preferences: ThemePreference[] = ["light", "dark", "system"]; |
||||
|
const currentIndex = preferences.indexOf(preference); |
||||
|
const nextPreference = preferences[(currentIndex + 1) % preferences.length]; |
||||
|
setPreference(nextPreference); |
||||
|
}; |
||||
|
|
||||
return ( |
return ( |
||||
<button |
<button |
||||
type="button" |
type="button" |
||||
className={`transition-all duration-300 hover:text-accent ${className}`} |
className={cn( |
||||
|
"transition-all duration-300 scale-100 cursor-pointer m-6 p-2", |
||||
|
className, |
||||
|
)} |
||||
onClick={toggleTheme} |
onClick={toggleTheme} |
||||
aria-label={`Current theme: ${theme}. Click to change theme.`} |
aria-label={ |
||||
|
preference === "system" |
||||
|
? `System theme (currently ${theme}). Click to change theme.` |
||||
|
: `Current theme: ${theme}. Click to change theme.` |
||||
|
} |
||||
> |
> |
||||
{themeIcons[theme]} |
{themeIcons[preference]} |
||||
</button> |
</button> |
||||
); |
); |
||||
} |
} |
||||
|
|||||
@ -1,67 +0,0 @@ |
|||||
import type React from "react"; |
|
||||
import { createContext, useContext, useEffect, useState } from "react"; |
|
||||
|
|
||||
type Theme = "light" | "dark" | "system"; |
|
||||
|
|
||||
interface ThemeContextType { |
|
||||
theme: Theme; |
|
||||
setTheme: (theme: Theme) => void; |
|
||||
} |
|
||||
|
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined); |
|
||||
|
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) { |
|
||||
const [theme, setTheme] = useState<Theme>(() => { |
|
||||
if (typeof window !== "undefined") { |
|
||||
const savedTheme = localStorage.getItem("theme") as Theme; |
|
||||
return savedTheme || "system"; |
|
||||
} |
|
||||
return "system"; |
|
||||
}); |
|
||||
|
|
||||
useEffect(() => { |
|
||||
const root = window.document.documentElement; |
|
||||
root.classList.remove("light", "dark"); |
|
||||
|
|
||||
if (theme === "system") { |
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") |
|
||||
.matches |
|
||||
? "dark" |
|
||||
: "light"; |
|
||||
root.classList.add(systemTheme); |
|
||||
} else { |
|
||||
root.classList.add(theme); |
|
||||
} |
|
||||
|
|
||||
localStorage.setItem("theme", theme); |
|
||||
}, [theme]); |
|
||||
|
|
||||
useEffect(() => { |
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); |
|
||||
|
|
||||
const handleChange = () => { |
|
||||
if (theme === "system") { |
|
||||
const root = window.document.documentElement; |
|
||||
root.classList.remove("light", "dark"); |
|
||||
root.classList.add(mediaQuery.matches ? "dark" : "light"); |
|
||||
} |
|
||||
}; |
|
||||
|
|
||||
mediaQuery.addEventListener("change", handleChange); |
|
||||
return () => mediaQuery.removeEventListener("change", handleChange); |
|
||||
}, [theme]); |
|
||||
|
|
||||
return ( |
|
||||
<ThemeContext.Provider value={{ theme, setTheme }}> |
|
||||
{children} |
|
||||
</ThemeContext.Provider> |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
export function useTheme() { |
|
||||
const context = useContext(ThemeContext); |
|
||||
if (context === undefined) { |
|
||||
throw new Error("useTheme must be used within a ThemeProvider"); |
|
||||
} |
|
||||
return context; |
|
||||
} |
|
||||
@ -1,37 +1,42 @@ |
|||||
import { useEffect, useState } from "react"; |
import { useCallback, useEffect, useState } from "react"; |
||||
|
|
||||
type Theme = "light" | "dark"; |
type Theme = "light" | "dark" | "system"; |
||||
|
|
||||
export function useTheme() { |
export function useTheme() { |
||||
const [theme, setTheme] = useState<Theme>(() => { |
const getSystemTheme = () => |
||||
if (typeof window === "undefined") return "light"; |
window.matchMedia("(prefers-color-scheme: dark)").matches |
||||
return ( |
? "dark" |
||||
(document.documentElement.getAttribute("data-theme") as Theme) || "light" |
: "light"; |
||||
); |
|
||||
}); |
const getStoredPreference = useCallback( |
||||
|
(): Theme => (localStorage.getItem("theme") as Theme) || "system", |
||||
|
[], |
||||
|
); |
||||
|
|
||||
|
const [preference, setPreference] = useState<Theme>(() => |
||||
|
typeof window !== "undefined" ? getStoredPreference() : "light", |
||||
|
); |
||||
|
|
||||
|
const theme = preference === "system" ? getSystemTheme() : preference; |
||||
|
|
||||
|
useEffect(() => { |
||||
|
document.documentElement.setAttribute("data-theme", theme); |
||||
|
}, [theme]); |
||||
|
|
||||
useEffect(() => { |
useEffect(() => { |
||||
const observer = new MutationObserver((mutations) => { |
if (preference !== "system") return; |
||||
for (const mutation of mutations) { |
|
||||
if ( |
const media = window.matchMedia("(prefers-color-scheme: dark)"); |
||||
mutation.type === "attributes" && |
const updateTheme = () => setPreference(getStoredPreference()); |
||||
mutation.attributeName === "data-theme" |
|
||||
) { |
media.addEventListener("change", updateTheme); |
||||
const newTheme = document.documentElement.getAttribute( |
return () => media.removeEventListener("change", updateTheme); |
||||
"data-theme", |
}, [preference, getStoredPreference]); |
||||
) as Theme; |
|
||||
setTheme(newTheme); |
const setPreferenceValue = (newPreference: Theme) => { |
||||
} |
localStorage.setItem("theme", newPreference); |
||||
} |
setPreference(newPreference); |
||||
}); |
}; |
||||
|
|
||||
observer.observe(document.documentElement, { |
return { theme, preference, setPreference: setPreferenceValue }; |
||||
attributes: true, |
|
||||
attributeFilter: ["data-theme"], |
|
||||
}); |
|
||||
|
|
||||
return () => observer.disconnect(); |
|
||||
}, []); |
|
||||
|
|
||||
return theme; |
|
||||
} |
} |
||||
|
|||||
Loading…
Reference in new issue