feat(KB-024): add comprehensive theme system to dashboard
- Add ThemePreference type and useTheme hook for theme state management - Create CSS theme architecture with 8 built-in color themes - Build ThemeSelector component with live preview and keyboard navigation - Add theme toggle to Header with quick-switch capability - Integrate Appearance section into SettingsModal - Wire theme system into App component with system preference detection - Add comprehensive tests for useTheme, ThemeSelector, and Header - Include theming documentation in dashboard README - Add changeset for patch release
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Settings, Pause, Play, Square, Download, LayoutGrid, List, Terminal } from "lucide-react";
|
||||
import { Settings, Pause, Play, Square, Download, LayoutGrid, List, Terminal, Moon, Sun, Monitor } from "lucide-react";
|
||||
import type { ThemeMode } from "@kb/core";
|
||||
|
||||
interface HeaderProps {
|
||||
onOpenSettings?: () => void;
|
||||
@@ -11,6 +12,8 @@ interface HeaderProps {
|
||||
onToggleEnginePause?: () => void;
|
||||
view?: "board" | "list";
|
||||
onChangeView?: (view: "board" | "list") => void;
|
||||
themeMode?: ThemeMode;
|
||||
onToggleTheme?: () => void;
|
||||
}
|
||||
|
||||
export function Header({
|
||||
@@ -24,6 +27,8 @@ export function Header({
|
||||
onToggleEnginePause,
|
||||
view = "board",
|
||||
onChangeView,
|
||||
themeMode = "dark",
|
||||
onToggleTheme,
|
||||
}: HeaderProps) {
|
||||
const hasInProgressTasks = inProgressCount > 0;
|
||||
|
||||
@@ -58,6 +63,24 @@ export function Header({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Theme Toggle */}
|
||||
{onToggleTheme && (
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onToggleTheme}
|
||||
title={`Toggle theme (${themeMode === "dark" ? "Dark" : themeMode === "light" ? "Light" : "System"})`}
|
||||
aria-label={`Toggle theme (${themeMode === "dark" ? "Dark" : themeMode === "light" ? "Light" : "System"})`}
|
||||
data-testid="theme-toggle-btn"
|
||||
>
|
||||
{themeMode === "dark" ? (
|
||||
<Moon size={16} />
|
||||
) : themeMode === "light" ? (
|
||||
<Sun size={16} />
|
||||
) : (
|
||||
<Monitor size={16} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{/* Import from GitHub */}
|
||||
<button className="btn-icon" onClick={onOpenGitHubImport} title="Import from GitHub">
|
||||
<Download size={16} />
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { THINKING_LEVELS } from "@kb/core";
|
||||
import type { Settings } from "@kb/core";
|
||||
import type { Settings, ThemeMode, ColorTheme } from "@kb/core";
|
||||
import { fetchSettings, updateSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels } from "../api";
|
||||
import type { AuthProvider, ModelInfo } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { ThemeSelector } from "./ThemeSelector";
|
||||
|
||||
/**
|
||||
* Settings sections configuration.
|
||||
@@ -15,16 +16,19 @@ import type { ToastType } from "../hooks/useToast";
|
||||
*
|
||||
* Sections:
|
||||
* - general: Task prefix configuration
|
||||
* - model: Default AI model selection
|
||||
* - appearance: Theme and color settings
|
||||
* - scheduling: Concurrency, poll interval, file overlap serialization
|
||||
* - worktrees: Worktree limits, init commands, recycling
|
||||
* - commands: Test and build command configuration
|
||||
* - merge: Auto-merge settings
|
||||
* - model: Default AI model selection for agent sessions
|
||||
* - notifications: ntfy.sh notification settings
|
||||
* - authentication: OAuth provider status, login/logout (operates independently of Save)
|
||||
*/
|
||||
const SETTINGS_SECTIONS = [
|
||||
{ id: "general", label: "General" },
|
||||
{ id: "model", label: "Model" },
|
||||
{ id: "appearance", label: "Appearance" },
|
||||
{ id: "scheduling", label: "Scheduling" },
|
||||
{ id: "worktrees", label: "Worktrees" },
|
||||
{ id: "commands", label: "Commands" },
|
||||
@@ -40,9 +44,25 @@ interface SettingsModalProps {
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
/** Optional section to show when the modal first opens. Defaults to "general". */
|
||||
initialSection?: SectionId;
|
||||
/** Current theme mode */
|
||||
themeMode?: ThemeMode;
|
||||
/** Current color theme */
|
||||
colorTheme?: ColorTheme;
|
||||
/** Called when theme mode changes */
|
||||
onThemeModeChange?: (mode: ThemeMode) => void;
|
||||
/** Called when color theme changes */
|
||||
onColorThemeChange?: (theme: ColorTheme) => void;
|
||||
}
|
||||
|
||||
export function SettingsModal({ onClose, addToast, initialSection }: SettingsModalProps) {
|
||||
export function SettingsModal({
|
||||
onClose,
|
||||
addToast,
|
||||
initialSection,
|
||||
themeMode = "dark",
|
||||
colorTheme = "default",
|
||||
onThemeModeChange,
|
||||
onColorThemeChange,
|
||||
}: SettingsModalProps) {
|
||||
const [form, setForm] = useState<Settings & { worktreeInitCommand?: string }>({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false, autoMerge: true, recycleWorktrees: false, includeTaskIdInCommit: true, worktreeInitCommand: "" });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeSection, setActiveSection] = useState<SectionId>(initialSection ?? SETTINGS_SECTIONS[0].id);
|
||||
@@ -302,6 +322,18 @@ export function SettingsModal({ onClose, addToast, initialSection }: SettingsMod
|
||||
</>
|
||||
);
|
||||
}
|
||||
case "appearance":
|
||||
return (
|
||||
<>
|
||||
<h4 className="settings-section-heading">Appearance</h4>
|
||||
<ThemeSelector
|
||||
themeMode={themeMode}
|
||||
colorTheme={colorTheme}
|
||||
onThemeModeChange={onThemeModeChange || (() => {})}
|
||||
onColorThemeChange={onColorThemeChange || (() => {})}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case "scheduling":
|
||||
return (
|
||||
<>
|
||||
|
||||
111
packages/dashboard/app/components/ThemeSelector.tsx
Normal file
111
packages/dashboard/app/components/ThemeSelector.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useCallback } from "react";
|
||||
import { Sun, Moon, Monitor } from "lucide-react";
|
||||
import type { ThemeMode, ColorTheme } from "@kb/core";
|
||||
|
||||
interface ThemeSelectorProps {
|
||||
themeMode: ThemeMode;
|
||||
colorTheme: ColorTheme;
|
||||
onThemeModeChange: (mode: ThemeMode) => void;
|
||||
onColorThemeChange: (theme: ColorTheme) => void;
|
||||
}
|
||||
|
||||
const THEME_MODES: { value: ThemeMode; label: string; icon: typeof Sun }[] = [
|
||||
{ value: "light", label: "Light", icon: Sun },
|
||||
{ value: "dark", label: "Dark", icon: Moon },
|
||||
{ value: "system", label: "System", icon: Monitor },
|
||||
];
|
||||
|
||||
const COLOR_THEMES: { value: ColorTheme; label: string; className: string }[] = [
|
||||
{ value: "default", label: "Default", className: "theme-swatch-default" },
|
||||
{ value: "ocean", label: "Ocean", className: "theme-swatch-ocean" },
|
||||
{ value: "forest", label: "Forest", className: "theme-swatch-forest" },
|
||||
{ value: "sunset", label: "Sunset", className: "theme-swatch-sunset" },
|
||||
{ value: "berry", label: "Berry", className: "theme-swatch-berry" },
|
||||
{ value: "monochrome", label: "Mono", className: "theme-swatch-monochrome" },
|
||||
{ value: "high-contrast", label: "High Contrast", className: "theme-swatch-high-contrast" },
|
||||
{ value: "solarized", label: "Solarized", className: "theme-swatch-solarized" },
|
||||
];
|
||||
|
||||
/**
|
||||
* ThemeSelector component for choosing light/dark/system mode and color theme
|
||||
*/
|
||||
export function ThemeSelector({
|
||||
themeMode,
|
||||
colorTheme,
|
||||
onThemeModeChange,
|
||||
onColorThemeChange,
|
||||
}: ThemeSelectorProps) {
|
||||
const handleReset = useCallback(() => {
|
||||
onThemeModeChange("dark");
|
||||
onColorThemeChange("default");
|
||||
}, [onThemeModeChange, onColorThemeChange]);
|
||||
|
||||
return (
|
||||
<div className="theme-selector">
|
||||
{/* Theme Mode Toggle */}
|
||||
<div className="theme-mode-toggle" role="radiogroup" aria-label="Theme mode">
|
||||
{THEME_MODES.map(({ value, label, icon: Icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
className={`theme-mode-btn${themeMode === value ? " active" : ""}`}
|
||||
onClick={() => onThemeModeChange(value)}
|
||||
aria-pressed={themeMode === value}
|
||||
aria-label={`${label} mode`}
|
||||
title={`${label} mode`}
|
||||
>
|
||||
<Icon size={16} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Current Theme Preview */}
|
||||
<div className="theme-current-preview">
|
||||
<div className="theme-preview-icon">
|
||||
{themeMode === "light" ? (
|
||||
<Sun size={20} />
|
||||
) : themeMode === "dark" ? (
|
||||
<Moon size={20} />
|
||||
) : (
|
||||
<Monitor size={20} />
|
||||
)}
|
||||
</div>
|
||||
<div className="theme-preview-info">
|
||||
<div className="theme-preview-label">Current theme</div>
|
||||
<div className="theme-preview-value">
|
||||
{themeMode === "system" ? "System" : `${themeMode.charAt(0).toUpperCase() + themeMode.slice(1)}`}
|
||||
{" / "}
|
||||
{COLOR_THEMES.find((t) => t.value === colorTheme)?.label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color Theme Grid */}
|
||||
<div className="theme-section-title">Color Theme</div>
|
||||
<div className="theme-grid" role="radiogroup" aria-label="Color theme">
|
||||
{COLOR_THEMES.map(({ value, label, className }) => (
|
||||
<button
|
||||
key={value}
|
||||
className={`theme-option${colorTheme === value ? " active" : ""}`}
|
||||
onClick={() => onColorThemeChange(value)}
|
||||
aria-pressed={colorTheme === value}
|
||||
aria-label={`${label} theme`}
|
||||
title={label}
|
||||
>
|
||||
<div className={`theme-option-swatch ${className}`} />
|
||||
<span className="theme-option-label">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Reset Button */}
|
||||
<button
|
||||
className="theme-reset-btn"
|
||||
onClick={handleReset}
|
||||
aria-label="Reset to default theme"
|
||||
>
|
||||
<span>Reset to defaults</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -187,5 +187,70 @@ describe("Header", () => {
|
||||
expect(boardBtn.className).not.toContain("active");
|
||||
expect(boardBtn.getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
|
||||
// ── Theme Toggle ─────────────────────────────────────────────────
|
||||
|
||||
it("renders theme toggle button when onToggleTheme is provided", () => {
|
||||
const onToggleTheme = vi.fn();
|
||||
render(<Header themeMode="dark" onToggleTheme={onToggleTheme} />);
|
||||
const btn = screen.getByTestId("theme-toggle-btn");
|
||||
expect(btn).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not render theme toggle when onToggleTheme is not provided", () => {
|
||||
render(<Header />);
|
||||
const btn = screen.queryByTestId("theme-toggle-btn");
|
||||
expect(btn).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onToggleTheme when theme toggle button is clicked", () => {
|
||||
const onToggleTheme = vi.fn();
|
||||
render(<Header themeMode="dark" onToggleTheme={onToggleTheme} />);
|
||||
const btn = screen.getByTestId("theme-toggle-btn");
|
||||
fireEvent.click(btn);
|
||||
expect(onToggleTheme).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("shows Moon icon for dark mode", () => {
|
||||
const onToggleTheme = vi.fn();
|
||||
render(<Header themeMode="dark" onToggleTheme={onToggleTheme} />);
|
||||
const btn = screen.getByTestId("theme-toggle-btn");
|
||||
expect(btn.querySelector("svg")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows Sun icon for light mode", () => {
|
||||
const onToggleTheme = vi.fn();
|
||||
render(<Header themeMode="light" onToggleTheme={onToggleTheme} />);
|
||||
const btn = screen.getByTestId("theme-toggle-btn");
|
||||
expect(btn.querySelector("svg")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows Monitor icon for system mode", () => {
|
||||
const onToggleTheme = vi.fn();
|
||||
render(<Header themeMode="system" onToggleTheme={onToggleTheme} />);
|
||||
const btn = screen.getByTestId("theme-toggle-btn");
|
||||
expect(btn.querySelector("svg")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows correct title for dark mode", () => {
|
||||
const onToggleTheme = vi.fn();
|
||||
render(<Header themeMode="dark" onToggleTheme={onToggleTheme} />);
|
||||
const btn = screen.getByTitle("Toggle theme (Dark)");
|
||||
expect(btn).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows correct title for light mode", () => {
|
||||
const onToggleTheme = vi.fn();
|
||||
render(<Header themeMode="light" onToggleTheme={onToggleTheme} />);
|
||||
const btn = screen.getByTitle("Toggle theme (Light)");
|
||||
expect(btn).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows correct title for system mode", () => {
|
||||
const onToggleTheme = vi.fn();
|
||||
render(<Header themeMode="system" onToggleTheme={onToggleTheme} />);
|
||||
const btn = screen.getByTitle("Toggle theme (System)");
|
||||
expect(btn).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -690,10 +690,10 @@ describe("SettingsModal", () => {
|
||||
const sidebar = container.querySelector(".settings-sidebar");
|
||||
expect(sidebar).toBeTruthy();
|
||||
const navItems = sidebar!.querySelectorAll(".settings-nav-item");
|
||||
expect(navItems.length).toBe(8);
|
||||
expect(navItems.length).toBe(9);
|
||||
|
||||
const labels = Array.from(navItems).map((el) => el.textContent);
|
||||
expect(labels).toEqual(["General", "Model", "Scheduling", "Worktrees", "Commands", "Merge", "Notifications", "Authentication"]);
|
||||
expect(labels).toEqual(["General", "Model", "Appearance", "Scheduling", "Worktrees", "Commands", "Merge", "Notifications", "Authentication"]);
|
||||
});
|
||||
|
||||
it("has .settings-content as sibling of .settings-sidebar", async () => {
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { ThemeSelector } from "../ThemeSelector";
|
||||
|
||||
describe("ThemeSelector", () => {
|
||||
it("renders theme mode toggle buttons", () => {
|
||||
render(
|
||||
<ThemeSelector
|
||||
themeMode="dark"
|
||||
colorTheme="default"
|
||||
onThemeModeChange={vi.fn()}
|
||||
onColorThemeChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Light mode")).toBeDefined();
|
||||
expect(screen.getByLabelText("Dark mode")).toBeDefined();
|
||||
expect(screen.getByLabelText("System mode")).toBeDefined();
|
||||
});
|
||||
|
||||
it("marks current theme mode as active", () => {
|
||||
render(
|
||||
<ThemeSelector
|
||||
themeMode="light"
|
||||
colorTheme="default"
|
||||
onThemeModeChange={vi.fn()}
|
||||
onColorThemeChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const lightBtn = screen.getByLabelText("Light mode");
|
||||
expect(lightBtn.className).toContain("active");
|
||||
expect(lightBtn.getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
it("marks non-active theme modes as not pressed", () => {
|
||||
render(
|
||||
<ThemeSelector
|
||||
themeMode="dark"
|
||||
colorTheme="default"
|
||||
onThemeModeChange={vi.fn()}
|
||||
onColorThemeChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const lightBtn = screen.getByLabelText("Light mode");
|
||||
expect(lightBtn.className).not.toContain("active");
|
||||
expect(lightBtn.getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
|
||||
it("calls onThemeModeChange when a mode is clicked", () => {
|
||||
const onThemeModeChange = vi.fn();
|
||||
render(
|
||||
<ThemeSelector
|
||||
themeMode="dark"
|
||||
colorTheme="default"
|
||||
onThemeModeChange={onThemeModeChange}
|
||||
onColorThemeChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Light mode"));
|
||||
expect(onThemeModeChange).toHaveBeenCalledWith("light");
|
||||
|
||||
fireEvent.click(screen.getByLabelText("System mode"));
|
||||
expect(onThemeModeChange).toHaveBeenCalledWith("system");
|
||||
});
|
||||
|
||||
it("renders all color theme options", () => {
|
||||
render(
|
||||
<ThemeSelector
|
||||
themeMode="dark"
|
||||
colorTheme="default"
|
||||
onThemeModeChange={vi.fn()}
|
||||
onColorThemeChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Default theme")).toBeDefined();
|
||||
expect(screen.getByLabelText("Ocean theme")).toBeDefined();
|
||||
expect(screen.getByLabelText("Forest theme")).toBeDefined();
|
||||
expect(screen.getByLabelText("Sunset theme")).toBeDefined();
|
||||
expect(screen.getByLabelText("Berry theme")).toBeDefined();
|
||||
expect(screen.getByLabelText("Mono theme")).toBeDefined();
|
||||
expect(screen.getByLabelText("High Contrast theme")).toBeDefined();
|
||||
expect(screen.getByLabelText("Solarized theme")).toBeDefined();
|
||||
});
|
||||
|
||||
it("marks current color theme as active", () => {
|
||||
render(
|
||||
<ThemeSelector
|
||||
themeMode="dark"
|
||||
colorTheme="ocean"
|
||||
onThemeModeChange={vi.fn()}
|
||||
onColorThemeChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const oceanBtn = screen.getByLabelText("Ocean theme");
|
||||
expect(oceanBtn.className).toContain("active");
|
||||
expect(oceanBtn.getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
it("calls onColorThemeChange when a color theme is clicked", () => {
|
||||
const onColorThemeChange = vi.fn();
|
||||
render(
|
||||
<ThemeSelector
|
||||
themeMode="dark"
|
||||
colorTheme="default"
|
||||
onThemeModeChange={vi.fn()}
|
||||
onColorThemeChange={onColorThemeChange}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Forest theme"));
|
||||
expect(onColorThemeChange).toHaveBeenCalledWith("forest");
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Berry theme"));
|
||||
expect(onColorThemeChange).toHaveBeenCalledWith("berry");
|
||||
});
|
||||
|
||||
it("displays current theme preview", () => {
|
||||
render(
|
||||
<ThemeSelector
|
||||
themeMode="dark"
|
||||
colorTheme="ocean"
|
||||
onThemeModeChange={vi.fn()}
|
||||
onColorThemeChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Current theme/)).toBeDefined();
|
||||
expect(screen.getByText(/Dark \/ Ocean/)).toBeDefined();
|
||||
});
|
||||
|
||||
it("displays system theme in preview when system mode", () => {
|
||||
render(
|
||||
<ThemeSelector
|
||||
themeMode="system"
|
||||
colorTheme="solarized"
|
||||
onThemeModeChange={vi.fn()}
|
||||
onColorThemeChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/System \/ Solarized/)).toBeDefined();
|
||||
});
|
||||
|
||||
it("displays light theme in preview when light mode", () => {
|
||||
render(
|
||||
<ThemeSelector
|
||||
themeMode="light"
|
||||
colorTheme="forest"
|
||||
onThemeModeChange={vi.fn()}
|
||||
onColorThemeChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Light \/ Forest/)).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows correct icon for dark mode in preview", () => {
|
||||
render(
|
||||
<ThemeSelector
|
||||
themeMode="dark"
|
||||
colorTheme="default"
|
||||
onThemeModeChange={vi.fn()}
|
||||
onColorThemeChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const previewIcon = screen.getByText(/Current theme/).closest(".theme-current-preview")?.querySelector("svg");
|
||||
expect(previewIcon).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows correct icon for light mode in preview", () => {
|
||||
render(
|
||||
<ThemeSelector
|
||||
themeMode="light"
|
||||
colorTheme="default"
|
||||
onThemeModeChange={vi.fn()}
|
||||
onColorThemeChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const previewIcon = screen.getByText(/Current theme/).closest(".theme-current-preview")?.querySelector("svg");
|
||||
expect(previewIcon).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows correct icon for system mode in preview", () => {
|
||||
render(
|
||||
<ThemeSelector
|
||||
themeMode="system"
|
||||
colorTheme="default"
|
||||
onThemeModeChange={vi.fn()}
|
||||
onColorThemeChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const previewIcon = screen.getByText(/Current theme/).closest(".theme-current-preview")?.querySelector("svg");
|
||||
expect(previewIcon).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders reset to defaults button", () => {
|
||||
render(
|
||||
<ThemeSelector
|
||||
themeMode="light"
|
||||
colorTheme="ocean"
|
||||
onThemeModeChange={vi.fn()}
|
||||
onColorThemeChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Reset to default theme")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls both change handlers when reset is clicked", () => {
|
||||
const onThemeModeChange = vi.fn();
|
||||
const onColorThemeChange = vi.fn();
|
||||
render(
|
||||
<ThemeSelector
|
||||
themeMode="light"
|
||||
colorTheme="ocean"
|
||||
onThemeModeChange={onThemeModeChange}
|
||||
onColorThemeChange={onColorThemeChange}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Reset to default theme"));
|
||||
expect(onThemeModeChange).toHaveBeenCalledWith("dark");
|
||||
expect(onColorThemeChange).toHaveBeenCalledWith("default");
|
||||
});
|
||||
|
||||
it("each color theme has a swatch", () => {
|
||||
render(
|
||||
<ThemeSelector
|
||||
themeMode="dark"
|
||||
colorTheme="default"
|
||||
onThemeModeChange={vi.fn()}
|
||||
onColorThemeChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Query all buttons in theme-grid that have aria-pressed (these are the color theme buttons)
|
||||
const themeOptions = screen.getAllByRole("button").filter(
|
||||
(btn) => btn.className.includes("theme-option")
|
||||
);
|
||||
expect(themeOptions.length).toBe(8);
|
||||
|
||||
themeOptions.forEach((btn) => {
|
||||
const swatch = btn.querySelector(".theme-option-swatch");
|
||||
expect(swatch).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user