Initial Commit
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest"
|
||||
import { useThemeStore } from "./themeStore"
|
||||
|
||||
describe("themeStore", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
document.documentElement.classList.remove("dark")
|
||||
useThemeStore.setState({ theme: "system" })
|
||||
useThemeStore.getState().applyTheme()
|
||||
})
|
||||
|
||||
it("setTheme('light') 적용 시 html.dark 제거 + resolved=light", () => {
|
||||
useThemeStore.getState().setTheme("light")
|
||||
expect(useThemeStore.getState().theme).toBe("light")
|
||||
expect(useThemeStore.getState().resolved).toBe("light")
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(false)
|
||||
})
|
||||
|
||||
it("setTheme('dark') 적용 시 html.dark 추가 + resolved=dark", () => {
|
||||
useThemeStore.getState().setTheme("dark")
|
||||
expect(useThemeStore.getState().theme).toBe("dark")
|
||||
expect(useThemeStore.getState().resolved).toBe("dark")
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true)
|
||||
})
|
||||
|
||||
it("setTheme('system') + matchMedia=dark 면 resolved=dark", () => {
|
||||
const spy = vi.spyOn(window, "matchMedia").mockImplementation(
|
||||
(q) =>
|
||||
({
|
||||
matches: true,
|
||||
media: q,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}) as unknown as MediaQueryList
|
||||
)
|
||||
useThemeStore.getState().setTheme("system")
|
||||
expect(useThemeStore.getState().resolved).toBe("dark")
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it("cycleTheme: light → dark → system → light", () => {
|
||||
useThemeStore.getState().setTheme("light")
|
||||
useThemeStore.getState().cycleTheme()
|
||||
expect(useThemeStore.getState().theme).toBe("dark")
|
||||
useThemeStore.getState().cycleTheme()
|
||||
expect(useThemeStore.getState().theme).toBe("system")
|
||||
useThemeStore.getState().cycleTheme()
|
||||
expect(useThemeStore.getState().theme).toBe("light")
|
||||
})
|
||||
|
||||
it("setTheme 결과가 localStorage에 영속됨 (theme만 저장, resolved 제외)", () => {
|
||||
useThemeStore.getState().setTheme("dark")
|
||||
const raw = localStorage.getItem("theme-store")
|
||||
expect(raw).toBeTruthy()
|
||||
const parsed = JSON.parse(raw!)
|
||||
expect(parsed.state.theme).toBe("dark")
|
||||
expect(parsed.state.resolved).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import { create } from "zustand"
|
||||
import { persist, createJSONStorage } from "zustand/middleware"
|
||||
|
||||
export type Theme = "light" | "dark" | "system"
|
||||
export type ResolvedTheme = "light" | "dark"
|
||||
|
||||
interface ThemeState {
|
||||
theme: Theme
|
||||
resolved: ResolvedTheme
|
||||
setTheme: (theme: Theme) => void
|
||||
cycleTheme: () => void
|
||||
/** 현재 theme 값으로 resolved를 다시 계산하고 html.dark를 토글. */
|
||||
applyTheme: () => void
|
||||
}
|
||||
|
||||
const MEDIA_QUERY = "(prefers-color-scheme: dark)"
|
||||
|
||||
function systemTheme(): ResolvedTheme {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return "light"
|
||||
return window.matchMedia(MEDIA_QUERY).matches ? "dark" : "light"
|
||||
}
|
||||
|
||||
function resolve(theme: Theme): ResolvedTheme {
|
||||
return theme === "system" ? systemTheme() : theme
|
||||
}
|
||||
|
||||
function applyToDocument(resolved: ResolvedTheme) {
|
||||
if (typeof document === "undefined") return
|
||||
document.documentElement.classList.toggle("dark", resolved === "dark")
|
||||
document.documentElement.style.colorScheme = resolved
|
||||
}
|
||||
|
||||
const ORDER: Theme[] = ["light", "dark", "system"]
|
||||
|
||||
export const useThemeStore = create<ThemeState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
theme: "light",
|
||||
resolved: resolve("light"),
|
||||
setTheme: (theme) => {
|
||||
const resolved = resolve(theme)
|
||||
applyToDocument(resolved)
|
||||
set({ theme, resolved })
|
||||
},
|
||||
cycleTheme: () => {
|
||||
const idx = ORDER.indexOf(get().theme)
|
||||
const next = ORDER[(idx + 1) % ORDER.length]
|
||||
get().setTheme(next)
|
||||
},
|
||||
applyTheme: () => {
|
||||
const resolved = resolve(get().theme)
|
||||
applyToDocument(resolved)
|
||||
set({ resolved })
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "theme-store",
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({ theme: state.theme }),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
// hydration 직후 1회: 복원된 theme로 resolved 재계산 + DOM 반영
|
||||
state?.applyTheme()
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
/** 모듈 import 시점에 matchMedia 변화 구독. system 모드일 때만 resolved 갱신. */
|
||||
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
|
||||
const mq = window.matchMedia(MEDIA_QUERY)
|
||||
const handler = () => {
|
||||
if (useThemeStore.getState().theme === "system") {
|
||||
useThemeStore.getState().applyTheme()
|
||||
}
|
||||
}
|
||||
// addEventListener 미지원 폴백 (구 Safari)
|
||||
if (typeof mq.addEventListener === "function") {
|
||||
mq.addEventListener("change", handler)
|
||||
} else if (
|
||||
typeof (mq as MediaQueryList & { addListener?: typeof handler }).addListener === "function"
|
||||
) {
|
||||
;(mq as MediaQueryList & { addListener: (h: typeof handler) => void }).addListener(handler)
|
||||
}
|
||||
|
||||
// 초기 1회 적용 (persist rehydration 전이라도 OK — applyTheme이 안전하게 처리)
|
||||
useThemeStore.getState().applyTheme()
|
||||
}
|
||||
Reference in New Issue
Block a user