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" };
|
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 }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
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>
|
<body>{children}</body>
|
||||||
</html>
|
</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 { HideAmountsToggle, HIDE_AMOUNTS_EVENT } from "./HideAmountsToggle";
|
||||||
export type { HideAmountsToggleProps } 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 { TabBar } from "./TabBar";
|
||||||
export type { TabBarProps, TabKey } from "./TabBar";
|
export type { TabBarProps, TabKey } from "./TabBar";
|
||||||
|
|
@ -22,3 +23,4 @@ export * from "./icons";
|
||||||
export * from "./IconChip";
|
export * from "./IconChip";
|
||||||
export * from "./SectionHeader";
|
export * from "./SectionHeader";
|
||||||
export * from "./EmptyState";
|
export * from "./EmptyState";
|
||||||
|
export * from "./SyncButton";
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
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 type { IconName } from "@/ds";
|
||||||
import { useMe, useSettings } from "@/api/hooks/reads";
|
import { useMe, useSettings } from "@/api/hooks/reads";
|
||||||
import { profileStrings } from "./strings";
|
import { profileStrings } from "./strings";
|
||||||
|
|
@ -101,6 +101,19 @@ export function ProfileView() {
|
||||||
</nav>
|
</nav>
|
||||||
</Card>
|
</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>
|
<Card>
|
||||||
<ConnectedBanks />
|
<ConnectedBanks />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,10 @@ export const profileStrings = {
|
||||||
display: {
|
display: {
|
||||||
title: "Дэлгэц ба нууцлал",
|
title: "Дэлгэц ба нууцлал",
|
||||||
hideAmounts: "Үнийн дүн нуух",
|
hideAmounts: "Үнийн дүн нуух",
|
||||||
|
themeMode: "Дэлгэцийн горим",
|
||||||
|
themeSystem: "Систем",
|
||||||
|
themeLight: "Гэрэл",
|
||||||
|
themeDark: "Харанхуй",
|
||||||
},
|
},
|
||||||
banks: {
|
banks: {
|
||||||
title: "Банкууд",
|
title: "Банкууд",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue