merge feat/dark: dark mode + theme toggle
# Conflicts: # web/src/ds/index.ts
This commit is contained in:
commit
6260cc3479
6 changed files with 180 additions and 3 deletions
|
|
@ -2,9 +2,31 @@ import "./globals.css";
|
|||
|
||||
export const metadata = { title: "Mercury" };
|
||||
|
||||
// Runs before paint (in <head>, ahead of hydration) to set the Seed
|
||||
// color-mode attribute from the persisted preference, avoiding a
|
||||
// flash-of-incorrect-theme: the server always renders "system" (no
|
||||
// localStorage access during SSR), so without this the client would briefly
|
||||
// paint light/system before this script could run post-hydration.
|
||||
const THEME_INIT_SCRIPT = `
|
||||
(function () {
|
||||
try {
|
||||
var v = localStorage.getItem("mercury.theme");
|
||||
var mode = v === "light" ? "light-only" : v === "dark" ? "dark-only" : "system";
|
||||
document.documentElement.setAttribute("data-seed-color-mode", mode);
|
||||
} catch (e) {}
|
||||
})();
|
||||
`;
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="mn" data-seed data-seed-color-mode="light-only">
|
||||
// suppressHydrationWarning: the inline script below intentionally
|
||||
// rewrites data-seed-color-mode before hydration (to the persisted
|
||||
// preference), so it will legitimately differ from this "system"
|
||||
// server-rendered value — React should not warn about or "fix" that.
|
||||
<html lang="mn" data-seed data-seed-color-mode="system" suppressHydrationWarning>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
49
src/ds/ThemeToggle.test.tsx
Normal file
49
src/ds/ThemeToggle.test.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { it, expect, beforeEach } from "vitest";
|
||||
import { ThemeToggle, getStoredTheme } from "./ThemeToggle";
|
||||
|
||||
beforeEach(() => {
|
||||
try {
|
||||
localStorage.clear();
|
||||
} catch {}
|
||||
document.documentElement.removeAttribute("data-seed-color-mode");
|
||||
});
|
||||
|
||||
it("defaults to system, and picks the persisted choice back up on remount", () => {
|
||||
expect(getStoredTheme()).toBe("system");
|
||||
|
||||
render(<ThemeToggle />);
|
||||
expect(screen.getByText("Систем")).toBeInTheDocument();
|
||||
expect(screen.getByText("Гэрэл")).toBeInTheDocument();
|
||||
expect(screen.getByText("Харанхуй")).toBeInTheDocument();
|
||||
|
||||
cleanup();
|
||||
localStorage.setItem("mercury.theme", "dark");
|
||||
render(<ThemeToggle />);
|
||||
const darkInput = screen.getByText("Харанхуй").closest("label")!.querySelector("input")!;
|
||||
expect(darkInput).toBeChecked();
|
||||
});
|
||||
|
||||
it("selecting dark persists the choice and flips the live data-seed-color-mode attribute", () => {
|
||||
render(<ThemeToggle />);
|
||||
|
||||
const darkInput = screen.getByText("Харанхуй").closest("label")!.querySelector("input")!;
|
||||
fireEvent.click(darkInput);
|
||||
|
||||
expect(getStoredTheme()).toBe("dark");
|
||||
expect(document.documentElement.getAttribute("data-seed-color-mode")).toBe("dark-only");
|
||||
});
|
||||
|
||||
it("selecting light and back to system round-trips through localStorage + the html attribute", () => {
|
||||
render(<ThemeToggle />);
|
||||
|
||||
const lightInput = screen.getByText("Гэрэл").closest("label")!.querySelector("input")!;
|
||||
fireEvent.click(lightInput);
|
||||
expect(getStoredTheme()).toBe("light");
|
||||
expect(document.documentElement.getAttribute("data-seed-color-mode")).toBe("light-only");
|
||||
|
||||
const systemInput = screen.getByText("Систем").closest("label")!.querySelector("input")!;
|
||||
fireEvent.click(systemInput);
|
||||
expect(getStoredTheme()).toBe("system");
|
||||
expect(document.documentElement.getAttribute("data-seed-color-mode")).toBe("system");
|
||||
});
|
||||
87
src/ds/ThemeToggle.tsx
Normal file
87
src/ds/ThemeToggle.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
SegmentedControlRoot,
|
||||
SegmentedControlItem,
|
||||
SegmentedControlItemHiddenInput,
|
||||
SegmentedControlIndicator,
|
||||
} from "@seed-design/react";
|
||||
|
||||
/** "system" follows the OS via prefers-color-scheme; "light"/"dark" pin it. */
|
||||
export type MercuryTheme = "system" | "light" | "dark";
|
||||
|
||||
const STORAGE_KEY = "mercury.theme";
|
||||
|
||||
/** Mirrors the inline script in app/layout.tsx (kept in sync manually — the
|
||||
* script must stay dependency-free/pre-hydration, so it can't import this). */
|
||||
function toColorMode(theme: MercuryTheme): "system" | "light-only" | "dark-only" {
|
||||
if (theme === "light") return "light-only";
|
||||
if (theme === "dark") return "dark-only";
|
||||
return "system";
|
||||
}
|
||||
|
||||
export function getStoredTheme(): MercuryTheme {
|
||||
try {
|
||||
const v = localStorage.getItem(STORAGE_KEY);
|
||||
if (v === "light" || v === "dark" || v === "system") return v;
|
||||
} catch {
|
||||
// localStorage unavailable (private mode, SSR) — fall through to default.
|
||||
}
|
||||
return "system";
|
||||
}
|
||||
|
||||
function persistTheme(theme: MercuryTheme): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, theme);
|
||||
} catch {
|
||||
// Best-effort; the toggle still works for the current page load.
|
||||
}
|
||||
}
|
||||
|
||||
function applyTheme(theme: MercuryTheme): void {
|
||||
if (typeof document === "undefined") return;
|
||||
document.documentElement.setAttribute("data-seed-color-mode", toColorMode(theme));
|
||||
}
|
||||
|
||||
export interface ThemeToggleProps {
|
||||
labels?: { system: string; light: string; dark: string };
|
||||
}
|
||||
|
||||
const DEFAULT_LABELS = { system: "Систем", light: "Гэрэл", dark: "Харанхуй" };
|
||||
|
||||
/** 3-way Систем / Гэрэл / Харанхуй display-mode control. Updates
|
||||
* localStorage and the live `data-seed-color-mode` attribute on `<html>`
|
||||
* immediately, so every `--seed-color-*` token flips without a reload. */
|
||||
export function ThemeToggle({ labels = DEFAULT_LABELS }: ThemeToggleProps) {
|
||||
// Lazy initializer runs on the client at mount (same pattern as
|
||||
// HideAmountsToggle/isHidden) so it picks up the real preference
|
||||
// immediately — layout.tsx's inline script has already applied it to the
|
||||
// DOM before this ever mounts, so both agree from the first paint.
|
||||
const [theme, setTheme] = React.useState<MercuryTheme>(() => getStoredTheme());
|
||||
|
||||
function handleChange(next: string) {
|
||||
const nextTheme = next as MercuryTheme;
|
||||
setTheme(nextTheme);
|
||||
persistTheme(nextTheme);
|
||||
applyTheme(nextTheme);
|
||||
}
|
||||
|
||||
return (
|
||||
<SegmentedControlRoot value={theme} onValueChange={handleChange} aria-label="Дэлгэцийн горим">
|
||||
<SegmentedControlIndicator />
|
||||
<SegmentedControlItem value="system">
|
||||
<SegmentedControlItemHiddenInput />
|
||||
{labels.system}
|
||||
</SegmentedControlItem>
|
||||
<SegmentedControlItem value="light">
|
||||
<SegmentedControlItemHiddenInput />
|
||||
{labels.light}
|
||||
</SegmentedControlItem>
|
||||
<SegmentedControlItem value="dark">
|
||||
<SegmentedControlItemHiddenInput />
|
||||
{labels.dark}
|
||||
</SegmentedControlItem>
|
||||
</SegmentedControlRoot>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,7 +7,8 @@ export type { AmountToggleProps } from "./AmountToggle";
|
|||
export { HideAmountsToggle, HIDE_AMOUNTS_EVENT } from "./HideAmountsToggle";
|
||||
export type { HideAmountsToggleProps } from "./HideAmountsToggle";
|
||||
|
||||
export { SyncButton } from "./SyncButton";
|
||||
export { ThemeToggle, getStoredTheme } from "./ThemeToggle";
|
||||
export type { MercuryTheme, ThemeToggleProps } from "./ThemeToggle";
|
||||
|
||||
export { TabBar } from "./TabBar";
|
||||
export type { TabBarProps, TabKey } from "./TabBar";
|
||||
|
|
@ -22,3 +23,4 @@ export * from "./icons";
|
|||
export * from "./IconChip";
|
||||
export * from "./SectionHeader";
|
||||
export * from "./EmptyState";
|
||||
export * from "./SyncButton";
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card, HideAmountsToggle, Icon, IconChip, MercuryButton } from "@/ds";
|
||||
import { Card, HideAmountsToggle, Icon, IconChip, MercuryButton, ThemeToggle } from "@/ds";
|
||||
import type { IconName } from "@/ds";
|
||||
import { useMe, useSettings } from "@/api/hooks/reads";
|
||||
import { profileStrings } from "./strings";
|
||||
|
|
@ -101,6 +101,19 @@ export function ProfileView() {
|
|||
</nav>
|
||||
</Card>
|
||||
|
||||
<Card style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: "var(--seed-color-fg-muted, #6b7280)" }}>
|
||||
{profileStrings.display.themeMode}
|
||||
</span>
|
||||
<ThemeToggle
|
||||
labels={{
|
||||
system: profileStrings.display.themeSystem,
|
||||
light: profileStrings.display.themeLight,
|
||||
dark: profileStrings.display.themeDark,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<ConnectedBanks />
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ export const profileStrings = {
|
|||
display: {
|
||||
title: "Дэлгэц ба нууцлал",
|
||||
hideAmounts: "Үнийн дүн нуух",
|
||||
themeMode: "Дэлгэцийн горим",
|
||||
themeSystem: "Систем",
|
||||
themeLight: "Гэрэл",
|
||||
themeDark: "Харанхуй",
|
||||
},
|
||||
banks: {
|
||||
title: "Банкууд",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue