iT邦幫忙

2025 iThome 鐵人賽

DAY 27
0
Modern Web

Modern Web × AI《拖延怪日記》:語錄陪伴擺脫拖延系列 第 27

【Day 27】— 入門 JavaScript 網頁架設:背景主題切換(深色模式 + 色彩主題)

  • 分享至 

  • xImage
  •  

摘要
今天把 Day 26 的全站樣式變數升級為「可切換主題」:支援 Light/Dark 與 色彩主題(專注藍/活力橘/治癒綠),並把使用者偏好存進 localStorage,下次開啟自動還原。也處理了 Chart.js 的文字/格線色彩同步,避免「換了一半」的違和感。

為什麼要做主題切換?

  • 自主性:讀者可依情境與喜好選擇淺色/深色與點綴色。
  • 一致性:按鈕、邊框、文字、圖表顏色同調。
  • 擴充性:CSS 變數集中管理,後續新增主題只要補 class 覆蓋即可。

學習重點

  • CSS 變數主題管理: :root 放基礎色;:root.dark 與 body.dark 覆蓋成深色;.theme-xxx 覆蓋品牌主色。
  • 狀態切換與保存:body.classList + document.documentElement.classList 切主題;localStorage 記住 mode 與 palette。
  • 平滑過渡:transition 讓主題切換不閃爍。
  • 主題選擇器 UI:下拉(模式)+小圓點(色盤),即時反應 & 可近性標記。
  • Chart.js 同步:Chart.defaults.color/borderColor 跟著主題更新。

核心流程

  1. HTML:加入「外觀設定」工具列(模式下拉+色盤)。
  2. CSS: 補 :root.dark + body.dark 與 .theme-blue/orange/green 覆蓋變數;加過渡與小圓點樣式。
  3. JS: 初始化讀系統偏好+ localStorage,同時在 <html><body> 切換 .dark;切換 palette;必要時重畫圖表。

實作

假設已具備:

  • Day 4–5:readRecords/writeRecords/addRecord
  • Day 12–19:頁面切換、回訪互動
  • Day 16–18:任務安排(含拖曳)與溫暖總結
  • Day 20–21:Chart.js 圖表(分布、占比、趨勢)
  • Day 22–25:匯出、雲端備份、語錄牆
  • Day 26:全站 styles.css(CSS 變數 + 基礎佈局)
  1. HTML:加入「外觀設定」工具列(全站共用)
    放在你的 <section id="resonanceScreen"> 之前(或你想放的任一主要區塊上方),不影響既有結構。
<!-- [Day27-NEW] 外觀設定(模式 + 色盤) -->
<section id="appearanceSection" class="toolbar" aria-label="外觀設定">
  <label for="themeModeSelect">模式:</label>
  <select id="themeModeSelect" aria-label="主題模式">
    <option value="system">跟隨系統</option>
    <option value="light">淺色</option>
    <option value="dark">深色</option>
  </select>

  <span class="palette-dots" role="radiogroup" aria-label="色彩主題">
    <button class="palette-dot" data-palette="blue"   aria-pressed="false" title="專注藍"></button>
    <button class="palette-dot" data-palette="orange" aria-pressed="false" title="活力橘"></button>
    <button class="palette-dot" data-palette="green"  aria-pressed="false" title="治癒綠"></button>
  </span>
</section>
  1. CSS:主題覆蓋、過渡與色盤按鈕
    放在 styles.css 檔尾,保留你原有的 Day 26 內容。
/* ========== [Day27-NEW] 主題切換與過渡 ========== */

/* 平滑過渡:避免切換閃爍 */
html, body, #recordFormSection, #historySection, #taskScheduleSection, #resonanceScreen,
#moodTestSection, #moodDecisionSection {
  transition: background-color .3s ease, color .3s ease, border-color .3s ease;
}

/* 螢幕閱讀器專用的隱藏 */
.sr-only {
  position: absolute !important;
  width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0,0,1px,1px); white-space: nowrap; border: 0;
}

/* <html> 套用深色變數,避免 DOM 區塊外仍是白色 */
:root.dark {
  --bg: #0b0c0f;
  --card: #121319;
  --text: #e9eef6;
  --muted: #9aa6b2;
  --border: #1d2330;
  --focus: #2563eb;
}

/* 色彩主題:覆蓋品牌主色 */
body.theme-blue  { --primary: #3b82f6; --primary-600: #2563eb; }
body.theme-orange{ --primary: #f59e0b; --primary-600: #d97706; }
body.theme-green { --primary: #10b981; --primary-600: #059669; }

/* 外觀設定區域 */
#appearanceSection { max-width: var(--maxw); margin: 0 auto; padding: var(--space-4); }

/* 色盤小圓點(可鍵盤操作) */
.palette-dots { display: inline-flex; gap: .5rem; margin-left: .25rem; }
.palette-dot {
  width: 22px; height: 22px; border-radius: 50%;
  border: 2px solid var(--border); background: var(--primary);
  display: inline-block; cursor: pointer;
}
.palette-dot[data-palette="blue"]   { background: #3b82f6; }
.palette-dot[data-palette="orange"] { background: #f59e0b; }
.palette-dot[data-palette="green"]  { background: #10b981; }
.palette-dot[aria-pressed="true"] { outline: 2px solid var(--focus); outline-offset: 2px; }

/* 深色時的按鈕背景與邊框 hover 微調 */
body.dark button { background: #151823; border-color: #22283a; }
body.dark button:hover { background: #1a1f2d; }

/* Chart.js 的 canvas 背景維持透明,由容器承擔底色 */
canvas { background: transparent; }
  1. JS:主題狀態、事件與 Chart.js 同步
    把以下片段加進 script.js(放在常數區之後即可)。
// ========== [Day27-NEW] 主題切換:常數、節點 ==========
const THEME_MODE_KEY = 'theme_mode';       // 'system' | 'light' | 'dark'
const THEME_PALETTE_KEY = 'theme_palette'; // 'blue' | 'orange' | 'green'
const themeModeSelect = document.getElementById('themeModeSelect');
const paletteButtons = document.querySelectorAll('.palette-dot');

// 取得目前應用的實際 mode(考慮 system)
function resolveMode(mode) {
  const m = mode || localStorage.getItem(THEME_MODE_KEY) || 'system';
  if (m === 'dark') return 'dark';
  if (m === 'light') return 'light';
  return (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)
    ? 'dark' : 'light';
}

// 套用主題到 <html> + <body>,並保存偏好
function applyTheme({ mode, palette, save = true } = {}) {
  const m = mode ?? (localStorage.getItem(THEME_MODE_KEY) || 'system');
  const p = palette ?? (localStorage.getItem(THEME_PALETTE_KEY) || 'blue');

  // 先清除
  document.documentElement.classList.remove('dark');
  document.body.classList.remove('dark', 'theme-blue', 'theme-orange', 'theme-green');

  // 模式(同時套 <html> 與 <body>)
  if (resolveMode(m) === 'dark') {
    document.documentElement.classList.add('dark');
    document.body.classList.add('dark');
  }

  // 色彩主題
  document.body.classList.add(`theme-${p}`);

  // 同步 UI 狀態
  if (themeModeSelect) themeModeSelect.value = m;
  paletteButtons.forEach(btn => {
    btn.setAttribute('aria-pressed', String(btn.dataset.palette === p));
  });

  // 儲存偏好
  if (save) {
    localStorage.setItem(THEME_MODE_KEY, m);
    localStorage.setItem(THEME_PALETTE_KEY, p);
  }

  // 同步 Chart.js 顏色(若已載入)
  try {
    if (window.Chart) {
      const cs = getComputedStyle(document.documentElement);
      const text   = cs.getPropertyValue('--text').trim()   || '#eaeef3';
      const border = cs.getPropertyValue('--border').trim() || '#334155';
      Chart.defaults.color = text;
      Chart.defaults.borderColor = border;

      // 若在歷史頁,重畫一次(避免顏色不同步)
      if (historySection && !historySection.hidden && typeof renderMoodChartsAll === 'function') {
        renderMoodChartsAll();
      }
    }
  } catch {}
}

// UI 綁定:模式切換
themeModeSelect?.addEventListener('change', (e) => {
  applyTheme({ mode: e.target.value, save: true });
});

// UI 綁定:色盤切換(radiogroup 行為)
paletteButtons.forEach(btn => {
  btn.addEventListener('click', () => {
    const palette = btn.dataset.palette || 'blue';
    applyTheme({ palette, save: true });
  });
  // 可選:鍵盤支援(左右切換)
  btn.addEventListener('keydown', (e) => {
    const list = Array.from(paletteButtons);
    const i = list.indexOf(btn);
    if (e.key === 'ArrowRight') list[(i+1)%list.length].focus();
    if (e.key === 'ArrowLeft')  list[(i-1+list.length)%list.length].focus();
  });
});

// 監聽系統主題變更(在「跟隨系統」時即時反應)
try {
  const mq = window.matchMedia('(prefers-color-scheme: dark)');
  mq.addEventListener?.('change', () => {
    const mode = localStorage.getItem(THEME_MODE_KEY) || 'system';
    if (mode === 'system') applyTheme({ mode: 'system', save: true });
  });
} catch {}

// 初始化:若尚未設定,給預設(system + blue);並立即套用
(function initTheme() {
  if (!localStorage.getItem(THEME_MODE_KEY))     localStorage.setItem(THEME_MODE_KEY, 'system');
  if (!localStorage.getItem(THEME_PALETTE_KEY))  localStorage.setItem(THEME_PALETTE_KEY, 'blue');
  applyTheme({ save: false }); // 第一次套用不必再存
})();

備註

  • 我們將「模式(light/dark/system)」與「色盤(blue/orange/green)」分成兩個 key,讓日後新增主題更彈性。
  • applyTheme() 會在歷史頁時觸發 renderMoodChartsAll() 重畫,Chart.js 也跟著換字色/格線色。

驗證

  1. 切換模式:選「深色」→ 整個視窗背景(含 DOM 區塊外)、卡片、文字、邊框都轉深;切回「淺色」或「跟隨系統」正常。
  2. 切換色盤:點三個小圓點,觀察主要按鈕與任務卡(--primary)顏色改變。
  3. 重新整理:保留前次選擇(localStorage 已保存)。
  4. Chart.js:進「歷史回顧」頁,長條/圓餅/折線圖字色、座標軸與格線顏色與主題一致。
  5. 鍵盤可用:Tab 可到模式下拉與色盤按鈕;色盤可用左右方向鍵切換焦點。

上一篇
【Day 26】— 入門 JavaScript 網頁架設:CSS Flex & Grid 版面
下一篇
【Day 28】— 入門 JavaScript 網頁架設:動畫過場 + 拖曳高亮 + Ripple
系列文
Modern Web × AI《拖延怪日記》:語錄陪伴擺脫拖延28
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言