家計簿アプリを作る #57:グラフのダークモード対応

家計簿アプリ作成シリーズの第57回です。ダークモード自体は#31で対応済みでしたが、その後追加したChart.jsのグラフは軸ラベルや目盛り線の色が固定のままで、ダークモード時に見えにくくなっていました。グラフもダークモードに追従するようにします。

ダークモード判定はMutationObserver

matchMedia('(prefers-color-scheme: dark)') はOSの設定しか検知できず、アプリの DarkMode コンポーネントによる手動切り替え(OS設定を上書きする)には対応できません。DarkMode コンポーネントは <html>dark クラスを付け外ししているだけなので、MutationObserver でこのクラス変化を直接監視する方法にしました。

$state/$effectを使う共通ロジックは.svelte.tsにする

Svelte 5のrunes($state, $effect)は .svelte ファイル以外では使えず、共通ロジックとして切り出す場合は拡張子を .svelte.ts にする必要があります。

// src/lib/utils/darkMode.svelte.ts
export function createDarkModeState() {
  let isDark = $state(false);

  $effect(() => {
    isDark = document.documentElement.classList.contains('dark');
    const observer = new MutationObserver(() => {
      isDark = document.documentElement.classList.contains('dark');
    });
    observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
    return () => observer.disconnect();
  });

  return {
    get isDark() {
      return isDark;
    }
  };
}

$effect のクリーンアップ関数(return () => observer.disconnect())で監視を確実に解除しているのがポイントです。importするときは .svelte.ts の最後の .ts は書きません。

import { createDarkModeState } from "$lib/utils/darkMode.svelte";

const darkMode = createDarkModeState();

グラフオプションを関数化して使い回す

各グラフごとに options を個別に書くのではなく、タイトル文言だけを引数に取る関数を用意し、共通の色設定(ダークモード時は明るい色、通常時は暗い色)を一箇所にまとめました。

function buildChartOptions(titleText: string) {
  const color = darkMode.isDark ? "#e5e7eb" : "#374151";
  const gridColor = darkMode.isDark ? "#374151" : "#e5e7eb";
  return {
    plugins: {
      legend: { labels: { color } },
      title: { display: true, text: titleText, color },
    },
    scales: {
      x: { ticks: { color }, grid: { color: gridColor } },
      y: { ticks: { color }, grid: { color: gridColor } },
    },
  };
}
<Bar data={chartData} options={buildChartOptions("月次収支")} />
<Bar data={monthlyData} options={buildChartOptions("月次推移")} />
<Bar data={budgetChartData} options={buildChartOptions("カテゴリ別予算消化状況")} />

関数呼び出しはテンプレートが再描画されるたびに評価されるため、$derived にしなくても darkMode.isDark の最新値がそのまま反映されます。収支一覧ページの3つのグラフと、年間サマリーページのグラフの両方に同じ関数を適用し、実装を揃えました。

まとめ

ポイント 内容
ダークモード判定 matchMediaではなくMutationObserver<html>class変化を直接監視する
runesの共通化 $state/$effectを使う共通ロジックは.svelte.tsファイルにする
オプションの使い回し タイトル文言だけを引数にした関数でグラフごとのoptionsを組み立てる
← トップページに戻る