feat(spa): theme toggle (dark/light/auto) with flash-suppression
- uiStore: themeMode (dark|light|auto), persisted to localStorage, follows OS preference in auto mode via matchMedia listener - main.ts: initialize theme before first paint - AppTopbar: cycle button shows next-state icon (sun/desktop/moon) - tokens.css: .wp-theme-switching disables transitions during swap - 5 new uiStore tests; full suite 82 vitest green
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import { setActivePinia, createPinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useUiStore } from "../stores/uiStore";
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
document.documentElement.className = "";
|
||||
localStorage.clear();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
describe("uiStore — theme cycle", () => {
|
||||
it("defaults to dark when no preference is stored", () => {
|
||||
const ui = useUiStore();
|
||||
expect(ui.themeMode).toBe("dark");
|
||||
expect(ui.resolvedTheme).toBe("dark");
|
||||
});
|
||||
|
||||
it("cycles dark → light → auto → dark", () => {
|
||||
const ui = useUiStore();
|
||||
ui.cycleTheme(); expect(ui.themeMode).toBe("light");
|
||||
ui.cycleTheme(); expect(ui.themeMode).toBe("auto");
|
||||
ui.cycleTheme(); expect(ui.themeMode).toBe("dark");
|
||||
});
|
||||
|
||||
it("persists the chosen mode to localStorage", () => {
|
||||
const ui = useUiStore();
|
||||
ui.setThemeMode("light");
|
||||
expect(localStorage.getItem("wp-theme-mode")).toBe("light");
|
||||
});
|
||||
|
||||
it("applies wp-dark / wp-theme-light classes on initialize and removes flash class after window", () => {
|
||||
const ui = useUiStore();
|
||||
ui.setThemeMode("light");
|
||||
ui.initializeTheme();
|
||||
expect(document.documentElement.classList.contains("wp-theme-light")).toBe(true);
|
||||
expect(document.documentElement.classList.contains("wp-theme-switching")).toBe(true);
|
||||
vi.advanceTimersByTime(150);
|
||||
expect(document.documentElement.classList.contains("wp-theme-switching")).toBe(false);
|
||||
});
|
||||
|
||||
it("reads a previously stored mode on init", () => {
|
||||
localStorage.setItem("wp-theme-mode", "auto");
|
||||
const ui = useUiStore();
|
||||
expect(ui.themeMode).toBe("auto");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import Button from "primevue/button";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useUiStore } from "../stores/uiStore";
|
||||
@@ -7,6 +8,20 @@ const ui = useUiStore();
|
||||
const router = useRouter();
|
||||
const version = "1.4.0-dev";
|
||||
const logoSrc = `${import.meta.env.BASE_URL}images/favicon.svg`;
|
||||
|
||||
/** Icon for the theme button reflects the *next* state in the cycle. */
|
||||
const themeIcon = computed(() => {
|
||||
switch (ui.themeMode) {
|
||||
case "dark": return "pi pi-sun"; // next: light
|
||||
case "light": return "pi pi-desktop"; // next: auto
|
||||
default: return "pi pi-moon"; // next: dark
|
||||
}
|
||||
});
|
||||
const themeLabel = computed(() =>
|
||||
ui.themeMode === "dark" ? "Switch to light theme" :
|
||||
ui.themeMode === "light" ? "Switch to auto (system) theme" :
|
||||
"Switch to dark theme",
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -24,6 +39,12 @@ const logoSrc = `${import.meta.env.BASE_URL}images/favicon.svg`;
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<Button
|
||||
:icon="themeIcon" text rounded severity="secondary"
|
||||
:aria-label="themeLabel"
|
||||
:title="themeLabel"
|
||||
@click="ui.cycleTheme"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-cog" text rounded severity="secondary"
|
||||
aria-label="Settings"
|
||||
@@ -35,7 +56,7 @@ const logoSrc = `${import.meta.env.BASE_URL}images/favicon.svg`;
|
||||
|
||||
<style scoped>
|
||||
.topbar {
|
||||
height: 56px;
|
||||
height: var(--wp-topbar-h, 56px);
|
||||
background: var(--wp-bg);
|
||||
border-bottom: 1px solid var(--wp-border);
|
||||
display: flex;
|
||||
@@ -55,8 +76,12 @@ const logoSrc = `${import.meta.env.BASE_URL}images/favicon.svg`;
|
||||
color: var(--wp-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.brand:hover { color: #fff; }
|
||||
.brand:hover { color: var(--wp-text); filter: brightness(1.1); }
|
||||
.brand-logo { width: 24px; height: 24px; }
|
||||
.brand-name { letter-spacing: 0.2px; }
|
||||
.brand-version { font-size: 11px; color: var(--wp-text3); font-weight: 400; }
|
||||
.brand-version {
|
||||
font-size: 11px;
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
font-weight: 400;
|
||||
}
|
||||
</style>
|
||||
|
||||
+7
-7
@@ -9,16 +9,16 @@ import "./styles/theme.css";
|
||||
import "./styles/tailwind.css";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
|
||||
// Force PrimeVue dark mode by adding the matching class on the html root.
|
||||
// PrimeVue 4 treats `darkModeSelector` as a CSS selector that toggles dark
|
||||
// scope tokens; `":root"` is not a recognized variant, so we use a class
|
||||
// and apply it eagerly. Light mode is not supported in v1.
|
||||
document.documentElement.classList.add("wp-dark");
|
||||
import { useUiStore } from "./stores/uiStore";
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
|
||||
// Apply persisted theme before mount so the first paint is correct.
|
||||
// `useUiStore` reads localStorage and listens to OS preference changes.
|
||||
useUiStore(pinia).initializeTheme();
|
||||
app.use(PrimeVue, {
|
||||
theme: {
|
||||
preset: WildcardPreset,
|
||||
|
||||
@@ -1,20 +1,86 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
type Theme = "dark" | "light";
|
||||
/** User-selected theme mode. `"auto"` follows the OS `prefers-color-scheme`. */
|
||||
export type ThemeMode = "dark" | "light" | "auto";
|
||||
|
||||
const STORAGE_KEY = "wp-theme-mode";
|
||||
const FLASH_SUPPRESS_MS = 120;
|
||||
|
||||
function readStoredTheme(): ThemeMode {
|
||||
try {
|
||||
const v = localStorage.getItem(STORAGE_KEY);
|
||||
if (v === "dark" || v === "light" || v === "auto") return v;
|
||||
} catch {
|
||||
/* localStorage unavailable */
|
||||
}
|
||||
return "dark";
|
||||
}
|
||||
|
||||
function systemPrefersDark(): boolean {
|
||||
return typeof window !== "undefined"
|
||||
&& typeof window.matchMedia === "function"
|
||||
&& window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
}
|
||||
|
||||
export const useUiStore = defineStore("ui", () => {
|
||||
const theme = ref<Theme>("dark");
|
||||
const themeMode = ref<ThemeMode>(readStoredTheme());
|
||||
const sidebarCollapsed = ref(false);
|
||||
|
||||
function toggleTheme() {
|
||||
theme.value = theme.value === "dark" ? "light" : "dark";
|
||||
document.documentElement.classList.toggle("light-theme", theme.value === "light");
|
||||
/** Resolved theme — `"auto"` collapsed to dark/light via OS preference. */
|
||||
const resolvedTheme = computed<"dark" | "light">(() =>
|
||||
themeMode.value === "auto"
|
||||
? (systemPrefersDark() ? "dark" : "light")
|
||||
: themeMode.value,
|
||||
);
|
||||
|
||||
function applyThemeToDocument(mode: "dark" | "light") {
|
||||
const html = document.documentElement;
|
||||
// Briefly suppress transitions so the swap paints in one frame.
|
||||
html.classList.add("wp-theme-switching");
|
||||
html.classList.toggle("wp-dark", mode === "dark");
|
||||
html.classList.toggle("wp-theme-light", mode === "light");
|
||||
window.setTimeout(() => html.classList.remove("wp-theme-switching"), FLASH_SUPPRESS_MS);
|
||||
}
|
||||
|
||||
function setThemeMode(mode: ThemeMode) {
|
||||
themeMode.value = mode;
|
||||
try { localStorage.setItem(STORAGE_KEY, mode); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** Cycle dark → light → auto → dark. */
|
||||
function cycleTheme() {
|
||||
const next: ThemeMode =
|
||||
themeMode.value === "dark" ? "light"
|
||||
: themeMode.value === "light" ? "auto"
|
||||
: "dark";
|
||||
setThemeMode(next);
|
||||
}
|
||||
|
||||
function initializeTheme() {
|
||||
applyThemeToDocument(resolvedTheme.value);
|
||||
// React to OS theme changes when in auto mode.
|
||||
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const onChange = () => {
|
||||
if (themeMode.value === "auto") applyThemeToDocument(resolvedTheme.value);
|
||||
};
|
||||
mq.addEventListener("change", onChange);
|
||||
}
|
||||
watch(resolvedTheme, (m) => applyThemeToDocument(m));
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value;
|
||||
}
|
||||
|
||||
return { theme, sidebarCollapsed, toggleTheme, toggleSidebar };
|
||||
return {
|
||||
themeMode,
|
||||
resolvedTheme,
|
||||
sidebarCollapsed,
|
||||
cycleTheme,
|
||||
setThemeMode,
|
||||
initializeTheme,
|
||||
toggleSidebar,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -207,3 +207,15 @@ html.wp-theme-light {
|
||||
.wp-theme-dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ---------- Theme-switch flash suppression ----------
|
||||
Briefly added by uiStore.cycleTheme() so the dark/light swap paints in
|
||||
one frame instead of every transition fading independently. */
|
||||
.wp-theme-switching,
|
||||
.wp-theme-switching *,
|
||||
.wp-theme-switching *::before,
|
||||
.wp-theme-switching *::after {
|
||||
transition: none !important;
|
||||
animation-duration: 0s !important;
|
||||
animation-delay: 0s !important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user