File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
"use client";
import { useSyncExternalStore } from "react";
import {
DEFAULT_THEME_PREFERENCE,
THEME_ATTRIBUTE_NAME,
THEME_PREFERENCE_CYCLE,
THEME_STORAGE_KEY,
type ThemePreference,
} from "@/lib/constants/theme";
import styles from "../design.module.scss";
const LABELS: Record<ThemePreference, string> = {
system: "시스템",
light: "라이트",
dark: "다크",
};
/**
* `<html data-theme>` 자체가 SSOT이므로 React state를 두지 않고 DOM을 직접 구독한다
* (앱 헤더의 테마 토글과 동일한 원칙 — 서버/클라이언트 출력이 갈리지 않는다).
* 서버 스냅샷은 항상 기본값(system)이라 첫 렌더가 결정적이다.
*/
function subscribe(onChange: () => void): () => void {
const observer = new MutationObserver(onChange);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: [THEME_ATTRIBUTE_NAME],
});
return () => observer.disconnect();
}
function getSnapshot(): ThemePreference {
const value = document.documentElement.getAttribute(THEME_ATTRIBUTE_NAME);
return value === "light" || value === "dark" ? value : DEFAULT_THEME_PREFERENCE;
}
function getServerSnapshot(): ThemePreference {
return DEFAULT_THEME_PREFERENCE;
}
function applyTheme(next: ThemePreference): void {
const root = document.documentElement;
// 전환 트랜지션은 200ms 동안만 켜지는 opt-in이다(@fox/styles/_root.scss).
root.setAttribute("data-theme-transition", "");
if (next === "system") {
root.removeAttribute(THEME_ATTRIBUTE_NAME);
localStorage.removeItem(THEME_STORAGE_KEY);
} else {
root.setAttribute(THEME_ATTRIBUTE_NAME, next);
localStorage.setItem(THEME_STORAGE_KEY, next);
}
window.setTimeout(() => {
root.removeAttribute("data-theme-transition");
}, 200);
}
/** 카탈로그에서 라이트/다크 토큰을 눈으로 대조하기 위한 개발용 전환기. */
export function ThemeSwitch() {
const current = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
return (
<div className={styles.themeSwitch} role="group" aria-label="테마 전환">
{THEME_PREFERENCE_CYCLE.map((preference) => (
<button
key={preference}
type="button"
className={styles.themeButton}
aria-pressed={current === preference}
onClick={() => applyTheme(preference)}
>
{LABELS[preference]}
</button>
))}
</div>
);
}