+++ app/(protected)/(basic)/_hooks/use-theme-preference.ts
... | ... | @@ -0,0 +1,81 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useCallback } from 'react'; | |
| 4 | +import { | |
| 5 | + DEFAULT_THEME_PREFERENCE, | |
| 6 | + THEME_ATTRIBUTE_NAME, | |
| 7 | + THEME_PREFERENCE_CYCLE, | |
| 8 | + THEME_STORAGE_KEY, | |
| 9 | + type ThemePreference, | |
| 10 | +} from '@/lib/constants/theme'; | |
| 11 | + | |
| 12 | +// design이 globals.css에서 이 속성이 존재하는 동안만 색상 전환에 트랜지션을 건다(design 계획 | |
| 13 | +// §8.3). prefers-reduced-motion 분기는 그 CSS 쪽 셀렉터가 처리하므로 이 훅은 조건 없이 | |
| 14 | +// 부여/제거만 한다. | |
| 15 | +const THEME_TRANSITION_ATTRIBUTE_NAME = 'data-theme-transition'; | |
| 16 | +const THEME_TRANSITION_DURATION_MS = 200; | |
| 17 | + | |
| 18 | +export interface ThemePreferenceState { | |
| 19 | + cycleThemePreference: () => void; | |
| 20 | +} | |
| 21 | + | |
| 22 | +/** `<html data-theme>`에서 현재 선택을 읽는다. 속성이 없거나 알 수 없는 값이면 system이다. */ | |
| 23 | +function readThemePreference(): ThemePreference { | |
| 24 | + const value = document.documentElement.getAttribute(THEME_ATTRIBUTE_NAME); | |
| 25 | + return value === 'light' || value === 'dark' | |
| 26 | + ? value | |
| 27 | + : DEFAULT_THEME_PREFERENCE; | |
| 28 | +} | |
| 29 | + | |
| 30 | +/** 순환 순서(THEME_PREFERENCE_CYCLE)에서 다음 값을 계산한다. */ | |
| 31 | +function getNextThemePreference(current: ThemePreference): ThemePreference { | |
| 32 | + const currentIndex = THEME_PREFERENCE_CYCLE.indexOf(current); | |
| 33 | + const nextIndex = (currentIndex + 1) % THEME_PREFERENCE_CYCLE.length; | |
| 34 | + return THEME_PREFERENCE_CYCLE[nextIndex]; | |
| 35 | +} | |
| 36 | + | |
| 37 | +/** 선택값을 기기에 영속화한다. system은 저장값을 지워 "선택 없음" 상태로 되돌린다. */ | |
| 38 | +function persistThemePreference(next: ThemePreference): void { | |
| 39 | + try { | |
| 40 | + if (next === 'system') { | |
| 41 | + localStorage.removeItem(THEME_STORAGE_KEY); | |
| 42 | + } else { | |
| 43 | + localStorage.setItem(THEME_STORAGE_KEY, next); | |
| 44 | + } | |
| 45 | + } catch { | |
| 46 | + // localStorage 접근이 차단된 환경(시크릿 모드 등)에서도 조용히 무시한다 — <html data-theme>만 | |
| 47 | + // 으로 현재 세션 동안은 정상 동작하고, 다음 방문 시 다시 system으로 fail-safe한다. | |
| 48 | + } | |
| 49 | +} | |
| 50 | + | |
| 51 | +/** `<html>`에 선택값을 반영한다. system은 속성 부재로 표현한다(§4-1 규격). */ | |
| 52 | +function applyThemePreference(next: ThemePreference): void { | |
| 53 | + const root = document.documentElement; | |
| 54 | + if (next === 'system') { | |
| 55 | + root.removeAttribute(THEME_ATTRIBUTE_NAME); | |
| 56 | + } else { | |
| 57 | + root.setAttribute(THEME_ATTRIBUTE_NAME, next); | |
| 58 | + } | |
| 59 | + persistThemePreference(next); | |
| 60 | +} | |
| 61 | + | |
| 62 | +/** | |
| 63 | + * 헤더 테마 토글 버튼의 화면 로컬 로직(ViewModel 대응) — `<html data-theme>` 자체가 SSOT이므로 | |
| 64 | + * React state를 두지 않는다(§4-3). 클릭 시점에 DOM에서 현재값을 읽고 다음 값을 계산해 | |
| 65 | + * 속성/localStorage에 쓴다. `useState`/`useEffect`가 없어 hydration 불일치 표면적이 0이다. | |
| 66 | + */ | |
| 67 | +export function useThemePreference(): ThemePreferenceState { | |
| 68 | + const cycleThemePreference = useCallback(() => { | |
| 69 | + const root = document.documentElement; | |
| 70 | + const next = getNextThemePreference(readThemePreference()); | |
| 71 | + | |
| 72 | + // 트랜지션 부여 → 같은 tick에 data-theme 변경 → 200ms 후 트랜지션 속성 제거. | |
| 73 | + root.setAttribute(THEME_TRANSITION_ATTRIBUTE_NAME, ''); | |
| 74 | + applyThemePreference(next); | |
| 75 | + window.setTimeout(() => { | |
| 76 | + root.removeAttribute(THEME_TRANSITION_ATTRIBUTE_NAME); | |
| 77 | + }, THEME_TRANSITION_DURATION_MS); | |
| 78 | + }, []); | |
| 79 | + | |
| 80 | + return { cycleThemePreference }; | |
| 81 | +} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?