Initial Commit

This commit is contained in:
2026-09-16 17:22:14 +09:00
commit 858ee9e9da
335 changed files with 123898 additions and 0 deletions
@@ -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()
})
})
+87
View File
@@ -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()
}