Newer
Older
client_dwatch / components / theme / ThemeProvider.tsx
"use client";

import {
  createContext,
  useCallback,
  useContext,
  useLayoutEffect,
  useMemo,
  useState,
} from "react";
import { translate, type Lang } from "@/lib/i18n";
import type { ThemeName } from "@/lib/theme";
import {
  LANG_STORAGE_KEY,
  THEME_STORAGE_KEY,
  parseSavedLang,
  parseSavedTheme,
} from "@/lib/ui-prefs";

interface ThemeContextValue {
  theme: ThemeName;
  lang: Lang;
  toggleTheme: () => void;
  toggleLang: () => void;
  t: (text: string) => string;
}

const ThemeContext = createContext<ThemeContextValue | null>(null);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  // ★既定(dark / ja)で始める★ — サーバーも既定で描くので、最初の描画を一致させる(ハイドレーションの不一致を避ける)。
  // ★保存値は下の useLayoutEffect で読む(描く前)★。`useState(() => localStorage…)` にしないこと(指示書BC 3-0・D-183)
  const [theme, setTheme] = useState<ThemeName>("dark");
  const [lang, setLang] = useState<Lang>("ja");
  /** 保存値を読み終えたか。★読み終える前は <html> にも localStorage にも書かない★(既定値で上書きしない) */
  const [prefsLoaded, setPrefsLoaded] = useState(false);

  // ★保存した表示設定を、ハイドレーションの直後・描く前に読む★(ユーザー決定 2026-10-19 U1)。
  //   <html> の data-theme / lang は <head> のスクリプト(app/layout.tsx の PREFS_INLINE_SCRIPT)が先に入れている。
  //   ここでは JS で色・文字を決めている部品(THEME_TOKENS[theme]・t())のために state を合わせる。
  //   開発時の Strict Mode の再マウントで React が <html> の属性を消すので、下の effect が入れ直す(ガイドの「Re-applying attributes」)
  useLayoutEffect(() => {
    let savedTheme: ThemeName | null = null;
    let savedLang: Lang | null = null;
    try {
      savedTheme = parseSavedTheme(window.localStorage.getItem(THEME_STORAGE_KEY));
      savedLang = parseSavedLang(window.localStorage.getItem(LANG_STORAGE_KEY));
    } catch {
      // ★使えない環境(プライベートウィンドウ等)は既定のまま★
    }
    // eslint-disable-next-line react-hooks/set-state-in-effect -- 保存した表示設定を描く前に反映する(D-183)
    if (savedTheme) setTheme(savedTheme);
    if (savedLang) setLang(savedLang);
    setPrefsLoaded(true);
  }, []);

  useLayoutEffect(() => {
    if (!prefsLoaded) return;
    document.documentElement.dataset.theme = theme;
    document.documentElement.lang = lang === "ja" ? "ja" : "en";
    try {
      window.localStorage.setItem(THEME_STORAGE_KEY, theme);
      window.localStorage.setItem(LANG_STORAGE_KEY, lang);
    } catch {
      // ★保存できなくても画面は切り替わる★(その閲覧中だけ有効)
    }
  }, [theme, lang, prefsLoaded]);

  const toggleTheme = useCallback(() => {
    setTheme((prev) => (prev === "dark" ? "light" : "dark"));
  }, []);

  const toggleLang = useCallback(() => {
    setLang((prev) => (prev === "ja" ? "en" : "ja"));
  }, []);

  const t = useCallback((text: string) => translate(lang, text), [lang]);

  const value = useMemo(
    () => ({ theme, lang, toggleTheme, toggleLang, t }),
    [theme, lang, toggleTheme, toggleLang, t]
  );

  return (
    <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
  );
}

export function useTheme(): ThemeContextValue {
  const ctx = useContext(ThemeContext);
  if (!ctx) {
    throw new Error("useTheme must be used within ThemeProvider");
  }
  return ctx;
}