Skip to content

Commit 41c3c84

Browse files
committed
feat: add ThemeSwitcher component with color palette and font selection
- Add ThemeSwitcher dropdown menu with 5 color themes and 2 font options - Extract useFont hook from theme-context for independent font state access - Replace sidebar-layout's simple light/dark toggle with full ThemeSwitcher - Add themeSwitcher prop to SidebarLayout for customization/hiding - Export ThemeSwitcher and useFont from package index - Add component tests for ThemeSwitcher
1 parent df2c357 commit 41c3c84

7 files changed

Lines changed: 351 additions & 16 deletions

File tree

examples/vite-app/src/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ const searchSources: SearchSource[] = [
2525

2626
const App = () => {
2727
return (
28-
<AppShell title="File-Based Routing Demo" searchSources={searchSources}>
28+
<AppShell title="File-Based Routing Demo" searchSources={searchSources} defaultTheme="bloom">
2929
<SidebarLayout
3030
sidebar={
3131
<DefaultSidebar>

packages/core/src/components/sidebar/sidebar-layout.tsx

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
1+
import type { ReactNode } from "react";
12
import { SidebarProvider, SidebarInset, SidebarTrigger, useSidebar } from "@/components/sidebar";
2-
import { SunIcon } from "lucide-react";
33
import { AppShellOutlet } from "@/components/content";
4-
import { Button } from "@/components/button";
5-
import { useTheme } from "@/contexts/theme-context";
4+
import { ThemeSwitcher } from "@/components/theme-switcher";
65
import { DefaultSidebar } from "./default-sidebar";
76
import { DynamicBreadcrumb } from "@/components/dynamic-breadcrumb";
87

98
export type SidebarLayoutProps = {
9+
/**
10+
* Header theme control.
11+
*
12+
* @default Built-in **`ThemeSwitcher`** menu (all themes + **System**).
13+
* Pass **`null`** to hide. Pass a custom **`ReactNode`** to replace.
14+
*/
15+
themeSwitcher?: ReactNode;
16+
1017
/**
1118
* Custom content renderer.
1219
*
@@ -68,10 +75,7 @@ const HidableSidebarTrigger = () => {
6875

6976
export const SidebarLayout = (props: SidebarLayoutProps) => {
7077
const Children = props.children ? props.children({ Outlet: AppShellOutlet }) : null;
71-
const themeContext = useTheme();
72-
const toggleTheme = () => {
73-
themeContext.setTheme(themeContext.theme === "dark" ? "light" : "dark");
74-
};
78+
const themeSwitcher = props.themeSwitcher !== undefined ? props.themeSwitcher : <ThemeSwitcher />;
7579

7680
return (
7781
<SidebarProvider
@@ -88,11 +92,9 @@ export const SidebarLayout = (props: SidebarLayoutProps) => {
8892
<HidableSidebarTrigger />
8993
<DynamicBreadcrumb />
9094
</div>
91-
<div className="astw:flex astw:items-center astw:gap-2">
92-
<Button variant="outline" size="icon" onClick={toggleTheme}>
93-
<SunIcon />
94-
</Button>
95-
</div>
95+
{themeSwitcher !== null ? (
96+
<div className="astw:flex astw:items-center astw:gap-2">{themeSwitcher}</div>
97+
) : null}
9698
</div>
9799
</header>
98100
<div className="astw:flex astw:flex-col astw:gap-4 astw:flex-1 astw:min-h-0">
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { cleanup, render, screen, waitFor } from "@testing-library/react";
2+
import userEvent from "@testing-library/user-event";
3+
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
4+
5+
import { FONT_OPTIONS, THEME_OPTIONS, ThemeProvider } from "@/contexts/theme-context";
6+
7+
import { ThemeSwitcher } from "./theme-switcher";
8+
9+
/** happy-dom / Node can omit a full `localStorage`; ThemeProvider persists via it. */
10+
function installLocalStorageStub() {
11+
const map = new Map<string, string>();
12+
const ls = {
13+
getItem: (k: string) => map.get(k) ?? null,
14+
setItem: (k: string, v: string) => {
15+
map.set(k, v);
16+
},
17+
removeItem: (k: string) => {
18+
map.delete(k);
19+
},
20+
clear: () => map.clear(),
21+
key: (i: number) => [...map.keys()][i] ?? null,
22+
get length() {
23+
return map.size;
24+
},
25+
};
26+
Object.defineProperty(globalThis, "localStorage", {
27+
configurable: true,
28+
value: ls,
29+
});
30+
return map;
31+
}
32+
33+
let storageMap: Map<string, string>;
34+
35+
beforeAll(() => {
36+
storageMap = installLocalStorageStub();
37+
});
38+
39+
beforeEach(() => {
40+
storageMap.clear();
41+
});
42+
43+
afterEach(() => {
44+
cleanup();
45+
});
46+
47+
describe("ThemeSwitcher", () => {
48+
it("opens a menu listing every theme and font option", async () => {
49+
const user = userEvent.setup();
50+
51+
render(
52+
<ThemeProvider defaultTheme="light">
53+
<ThemeSwitcher />
54+
</ThemeProvider>,
55+
);
56+
57+
await user.click(screen.getByRole("button", { name: "Appearance" }));
58+
59+
await waitFor(() => {
60+
expect(screen.getAllByRole("menuitemradio").length).toBe(
61+
THEME_OPTIONS.length + FONT_OPTIONS.length,
62+
);
63+
});
64+
65+
for (const opt of THEME_OPTIONS) {
66+
expect(screen.getByRole("menuitemradio", { name: opt.label })).toBeDefined();
67+
}
68+
for (const opt of FONT_OPTIONS) {
69+
expect(screen.getByRole("menuitemradio", { name: opt.label })).toBeDefined();
70+
}
71+
});
72+
73+
it("exposes resolved palette on the trigger when system mode is selected", () => {
74+
render(
75+
<ThemeProvider defaultTheme="system">
76+
<ThemeSwitcher />
77+
</ThemeProvider>,
78+
);
79+
80+
const btn = screen.getByRole("button", { name: "Appearance" });
81+
expect(btn.getAttribute("title")).toMatch(/following system/i);
82+
expect(btn.getAttribute("title")).toMatch(/currently light|currently dark/i);
83+
});
84+
85+
it("applies selected palette when a radio item is activated", async () => {
86+
const user = userEvent.setup();
87+
88+
render(
89+
<ThemeProvider defaultTheme="light">
90+
<ThemeSwitcher />
91+
</ThemeProvider>,
92+
);
93+
94+
await user.click(screen.getByRole("button", { name: "Appearance" }));
95+
96+
await waitFor(() => {
97+
expect(screen.getByRole("menu")).toBeDefined();
98+
});
99+
100+
await user.click(screen.getByRole("menuitemradio", { name: "Bloom" }));
101+
102+
await waitFor(() => {
103+
expect(document.documentElement.dataset.theme).toBe("bloom");
104+
});
105+
expect(localStorage.getItem("appshell-ui-theme")).toBe("bloom");
106+
});
107+
108+
it("applies selected font when a font radio item is activated", async () => {
109+
const user = userEvent.setup();
110+
111+
render(
112+
<ThemeProvider defaultTheme="light" defaultFont="geist">
113+
<ThemeSwitcher />
114+
</ThemeProvider>,
115+
);
116+
117+
await user.click(screen.getByRole("button", { name: "Appearance" }));
118+
119+
await waitFor(() => {
120+
expect(screen.getByRole("menu")).toBeDefined();
121+
});
122+
123+
await user.click(screen.getByRole("menuitemradio", { name: "Inter" }));
124+
125+
await waitFor(() => {
126+
expect(document.documentElement.dataset.font).toBe("inter");
127+
});
128+
expect(localStorage.getItem("appshell-ui-font")).toBe("inter");
129+
});
130+
});
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import { Palette } from "lucide-react";
2+
3+
import { Menu } from "@/components/menu";
4+
import { Button } from "@/components/button";
5+
import { cn } from "@/lib/utils";
6+
import {
7+
useTheme,
8+
useFont,
9+
type ResolvedTheme,
10+
type Theme,
11+
type Font,
12+
THEME_OPTIONS,
13+
FONT_OPTIONS,
14+
} from "@/contexts/theme-context";
15+
16+
const RESOLVED_THEME_SHORT: Record<ResolvedTheme, string> = {
17+
light: "Light",
18+
dark: "Dark",
19+
cream: "Cream",
20+
bloom: "Bloom",
21+
};
22+
23+
/**
24+
* Decorative dual swatches — approximates each palette pair (accent + neutral)
25+
* for the picker grid.
26+
* Kept as static hex previews so the swatches render even before a theme stylesheet has loaded;
27+
* keep these in sync with the palette tokens in `assets/theme.css`.
28+
*/
29+
const THEME_PREVIEW: Record<Theme, { readonly a: `#${string}`; readonly b: `#${string}` }> = {
30+
light: { a: "#ffffff", b: "#d4d4d8" },
31+
dark: { a: "#3f3f46", b: "#d4d4d8" },
32+
cream: { a: "#f8f3e4", b: "#e2d4fe" },
33+
bloom: { a: "#535ae8", b: "#f8f3e4" },
34+
system: { a: "#52525b", b: "#7c73e6" },
35+
};
36+
37+
/** Font preview — `font-family` for the "Aa" sample so users see the face before selecting.
38+
* Mirrors the chain in `globals.css` (variable build first, then static family fallback). */
39+
const FONT_PREVIEW: Record<Font, string> = {
40+
geist: '"Geist Variable", "Geist Sans", ui-sans-serif, system-ui, sans-serif',
41+
inter: '"Inter Variable", "Inter", ui-sans-serif, system-ui, sans-serif',
42+
};
43+
44+
function isTheme(value: string): value is Theme {
45+
return THEME_OPTIONS.some((o) => o.value === value);
46+
}
47+
48+
function isFont(value: string): value is Font {
49+
return FONT_OPTIONS.some((o) => o.value === value);
50+
}
51+
52+
/** Shared radio-item chrome — used by both the color and font grids. */
53+
function radioItemClasses(active: boolean) {
54+
return cn(
55+
"astw:relative astw:flex astw:h-auto astw:w-full astw:cursor-default astw:select-none astw:flex-col astw:items-center astw:justify-center astw:gap-1.5 astw:rounded-xl astw:border-0 astw:bg-transparent astw:px-2 astw:py-2 astw:text-center astw:text-xs astw:font-medium astw:leading-tight astw:outline-hidden",
56+
"astw:data-highlighted:bg-muted/80 astw:data-highlighted:text-foreground",
57+
"astw:data-disabled:pointer-events-none astw:data-disabled:opacity-50",
58+
"[&_[data-slot=menu-radio-item-indicator]]:astw:hidden",
59+
active &&
60+
"astw:bg-primary/12 astw:ring-1 astw:ring-primary/25 astw:data-highlighted:bg-primary/[0.14]",
61+
);
62+
}
63+
64+
function ThemePreviewSwatches({ themeId }: { themeId: Theme }) {
65+
const { a, b } = THEME_PREVIEW[themeId];
66+
return (
67+
<div
68+
className="astw:flex astw:h-10 astw:w-full astw:max-w-[5.5rem] astw:items-center astw:justify-center astw:gap-1.5 astw:rounded-md astw:border astw:border-border/80 astw:bg-card astw:px-2"
69+
aria-hidden
70+
>
71+
<span
72+
className="astw:size-4 astw:shrink-0 astw:rounded-full astw:border astw:border-black/10 astw:shadow-sm"
73+
style={{ backgroundColor: a }}
74+
/>
75+
<span
76+
className="astw:size-4 astw:shrink-0 astw:rounded-full astw:border astw:border-black/10 astw:shadow-sm"
77+
style={{ backgroundColor: b }}
78+
/>
79+
</div>
80+
);
81+
}
82+
83+
function FontPreview({ fontId }: { fontId: Font }) {
84+
return (
85+
<div
86+
className="astw:flex astw:h-10 astw:w-full astw:items-center astw:justify-center astw:rounded-md astw:border astw:border-border/80 astw:bg-card astw:px-2"
87+
aria-hidden
88+
>
89+
<span
90+
className="astw:text-lg astw:font-medium astw:leading-none astw:text-foreground"
91+
style={{ fontFamily: FONT_PREVIEW[fontId] }}
92+
>
93+
Aa
94+
</span>
95+
</div>
96+
);
97+
}
98+
99+
/**
100+
* Appearance menu: two independent axes — color palette (top) and font (bottom).
101+
* Bound to stored `theme` and `font`; **System** stays explicit on the color axis.
102+
*/
103+
function ThemeSwitcher() {
104+
const { theme, resolvedTheme, setTheme } = useTheme();
105+
const { font, setFont } = useFont();
106+
107+
const fontLabel = FONT_OPTIONS.find((o) => o.value === font)?.label ?? font;
108+
const triggerTitle =
109+
theme === "system"
110+
? `Following system — currently ${RESOLVED_THEME_SHORT[resolvedTheme]} · ${fontLabel}`
111+
: "Choose appearance — color + font";
112+
113+
return (
114+
<Menu.Root modal={false}>
115+
<Menu.Trigger
116+
render={
117+
<Button
118+
type="button"
119+
variant="outline"
120+
size="icon"
121+
className="astw:shrink-0"
122+
aria-label="Appearance"
123+
title={triggerTitle}
124+
/>
125+
}
126+
>
127+
<Palette className="astw:size-4" aria-hidden />
128+
</Menu.Trigger>
129+
<Menu.Content
130+
position={{ side: "bottom", align: "end", sideOffset: 4 }}
131+
className="astw:min-w-[16.5rem] astw:rounded-xl astw:p-3"
132+
>
133+
<Menu.Group>
134+
<Menu.GroupLabel className="astw:px-0 astw:pb-2 astw:pt-0">Colors</Menu.GroupLabel>
135+
<Menu.RadioGroup
136+
className="astw:grid astw:grid-cols-3 astw:gap-2"
137+
value={theme}
138+
onValueChange={(value) => {
139+
if (typeof value === "string" && isTheme(value)) setTheme(value);
140+
}}
141+
>
142+
{THEME_OPTIONS.map((opt) => (
143+
<Menu.RadioItem
144+
key={opt.value}
145+
value={opt.value}
146+
className={radioItemClasses(theme === opt.value)}
147+
>
148+
<Menu.RadioItemIndicator className="astw:sr-only">
149+
{opt.label}
150+
</Menu.RadioItemIndicator>
151+
<ThemePreviewSwatches themeId={opt.value} />
152+
<span className="astw:block astw:w-full astw:truncate astw:px-0.5">
153+
{opt.label}
154+
</span>
155+
</Menu.RadioItem>
156+
))}
157+
</Menu.RadioGroup>
158+
</Menu.Group>
159+
160+
<Menu.Group className="astw:mt-5">
161+
<Menu.GroupLabel className="astw:px-0 astw:pb-2 astw:pt-0">Font</Menu.GroupLabel>
162+
<Menu.RadioGroup
163+
className="astw:grid astw:grid-cols-2 astw:gap-2"
164+
value={font}
165+
onValueChange={(value) => {
166+
if (typeof value === "string" && isFont(value)) setFont(value);
167+
}}
168+
>
169+
{FONT_OPTIONS.map((opt) => (
170+
<Menu.RadioItem
171+
key={opt.value}
172+
value={opt.value}
173+
className={radioItemClasses(font === opt.value)}
174+
>
175+
<Menu.RadioItemIndicator className="astw:sr-only">
176+
{opt.label}
177+
</Menu.RadioItemIndicator>
178+
<FontPreview fontId={opt.value} />
179+
<span className="astw:block astw:w-full astw:truncate astw:px-0.5">
180+
{opt.label}
181+
</span>
182+
</Menu.RadioItem>
183+
))}
184+
</Menu.RadioGroup>
185+
</Menu.Group>
186+
</Menu.Content>
187+
</Menu.Root>
188+
);
189+
}
190+
191+
export { ThemeSwitcher };

packages/core/src/contexts/theme-context.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { cleanup, render, act, waitFor } from "@testing-library/react";
22
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
33

4-
import { ThemeProvider, useTheme } from "./theme-context";
4+
import { ThemeProvider, useTheme, useFont } from "./theme-context";
55

66
/** happy-dom / Node can omit a full `localStorage`; ThemeProvider persists via it. */
77
function installLocalStorageStub() {
@@ -81,7 +81,7 @@ function ThemeProbe() {
8181
}
8282

8383
function FontProbe() {
84-
const { font } = useTheme();
84+
const { font } = useFont();
8585
return <span data-testid="font">{font}</span>;
8686
}
8787

0 commit comments

Comments
 (0)