Skip to content

Commit 28f42a2

Browse files
authored
Merge pull request #500 from precious-akpan/feat/465-appearance-settings-panel
feat: add appearance settings panel (DS-065)
2 parents 5608b7d + 52aac01 commit 28f42a2

2 files changed

Lines changed: 554 additions & 0 deletions

File tree

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
2+
import { render, screen, within } from "@testing-library/react"
3+
import userEvent from "@testing-library/user-event"
4+
import { axe } from "vitest-axe"
5+
6+
import { AppearanceSettingsPanel } from "./appearance-settings-panel"
7+
import { ThemeProvider } from "./theme-provider"
8+
9+
// Mock localStorage
10+
const localStorageMock = (() => {
11+
let store: Record<string, string> = {}
12+
13+
return {
14+
getItem: (key: string) => store[key] || null,
15+
setItem: (key: string, value: string) => {
16+
store[key] = value
17+
},
18+
clear: () => {
19+
store = {}
20+
},
21+
}
22+
})()
23+
24+
Object.defineProperty(window, "localStorage", {
25+
value: localStorageMock,
26+
})
27+
28+
// Helper to render within ThemeProvider
29+
function renderWithTheme(ui: React.ReactElement) {
30+
return render(<ThemeProvider>{ui}</ThemeProvider>)
31+
}
32+
33+
describe("AppearanceSettingsPanel", () => {
34+
beforeEach(() => {
35+
localStorageMock.clear()
36+
// Reset system preference
37+
window.matchMedia = vi.fn().mockImplementation((query) => ({
38+
matches: query === "(prefers-color-scheme: dark)" ? false : true,
39+
media: query,
40+
onchange: null,
41+
addEventListener: vi.fn(),
42+
removeEventListener: vi.fn(),
43+
dispatchEvent: vi.fn(),
44+
}))
45+
})
46+
47+
describe("Rendering", () => {
48+
it("renders with default heading", () => {
49+
renderWithTheme(<AppearanceSettingsPanel />)
50+
expect(screen.getByText("Appearance")).toBeInTheDocument()
51+
})
52+
53+
it("renders with custom heading", () => {
54+
renderWithTheme(<AppearanceSettingsPanel heading="Theme Settings" />)
55+
expect(screen.getByText("Theme Settings")).toBeInTheDocument()
56+
})
57+
58+
it("renders without heading when heading is null", () => {
59+
renderWithTheme(<AppearanceSettingsPanel heading={null} />)
60+
expect(screen.queryByRole("heading")).not.toBeInTheDocument()
61+
})
62+
63+
it("renders all three theme options", () => {
64+
renderWithTheme(<AppearanceSettingsPanel />)
65+
expect(screen.getByLabelText(/Light/i)).toBeInTheDocument()
66+
expect(screen.getByLabelText(/Dark/i)).toBeInTheDocument()
67+
expect(screen.getByLabelText(/System/i)).toBeInTheDocument()
68+
})
69+
70+
it("renders option descriptions", () => {
71+
renderWithTheme(<AppearanceSettingsPanel />)
72+
expect(screen.getByText("Always use light theme")).toBeInTheDocument()
73+
expect(screen.getByText("Always use dark theme")).toBeInTheDocument()
74+
expect(screen.getByText("Sync with your device settings")).toBeInTheDocument()
75+
})
76+
77+
it("renders preview cards by default", () => {
78+
const { container } = renderWithTheme(<AppearanceSettingsPanel />)
79+
// Previews are aria-hidden divs
80+
const previews = container.querySelectorAll('[aria-hidden="true"]')
81+
expect(previews.length).toBeGreaterThanOrEqual(3)
82+
})
83+
84+
it("hides preview cards when showPreviews is false", () => {
85+
const { container } = renderWithTheme(
86+
<AppearanceSettingsPanel showPreviews={false} />
87+
)
88+
// Count should be less (no preview cards)
89+
const previews = container.querySelectorAll('[aria-hidden="true"]')
90+
expect(previews.length).toBe(0)
91+
})
92+
})
93+
94+
describe("Theme Selection", () => {
95+
it("defaults to system theme", () => {
96+
renderWithTheme(<AppearanceSettingsPanel />)
97+
const systemRadio = screen.getByLabelText(/System/i)
98+
expect(systemRadio).toBeChecked()
99+
})
100+
101+
it("allows selecting light theme", async () => {
102+
const user = userEvent.setup()
103+
renderWithTheme(<AppearanceSettingsPanel />)
104+
105+
const lightRadio = screen.getByLabelText(/Light/i)
106+
await user.click(lightRadio)
107+
108+
expect(lightRadio).toBeChecked()
109+
})
110+
111+
it("allows selecting dark theme", async () => {
112+
const user = userEvent.setup()
113+
renderWithTheme(<AppearanceSettingsPanel />)
114+
115+
const darkRadio = screen.getByLabelText(/Dark/i)
116+
await user.click(darkRadio)
117+
118+
expect(darkRadio).toBeChecked()
119+
})
120+
121+
it("can switch between themes", async () => {
122+
const user = userEvent.setup()
123+
renderWithTheme(<AppearanceSettingsPanel />)
124+
125+
const lightRadio = screen.getByLabelText(/Light/i)
126+
const darkRadio = screen.getByLabelText(/Dark/i)
127+
const systemRadio = screen.getByLabelText(/System/i)
128+
129+
// Start with system (default)
130+
expect(systemRadio).toBeChecked()
131+
132+
// Switch to light
133+
await user.click(lightRadio)
134+
expect(lightRadio).toBeChecked()
135+
expect(darkRadio).not.toBeChecked()
136+
137+
// Switch to dark
138+
await user.click(darkRadio)
139+
expect(darkRadio).toBeChecked()
140+
expect(lightRadio).not.toBeChecked()
141+
142+
// Back to system
143+
await user.click(systemRadio)
144+
expect(systemRadio).toBeChecked()
145+
})
146+
})
147+
148+
describe("Persistence", () => {
149+
it("persists selection to localStorage", async () => {
150+
const user = userEvent.setup()
151+
renderWithTheme(<AppearanceSettingsPanel />)
152+
153+
const darkRadio = screen.getByLabelText(/Dark/i)
154+
await user.click(darkRadio)
155+
156+
expect(localStorageMock.getItem("so4-theme")).toBe("dark")
157+
})
158+
159+
it("loads persisted selection on mount", () => {
160+
localStorageMock.setItem("so4-theme", "light")
161+
162+
renderWithTheme(<AppearanceSettingsPanel />)
163+
164+
const lightRadio = screen.getByLabelText(/Light/i)
165+
expect(lightRadio).toBeChecked()
166+
})
167+
168+
it("applies theme without page reload", async () => {
169+
const user = userEvent.setup()
170+
renderWithTheme(<AppearanceSettingsPanel />)
171+
172+
const darkRadio = screen.getByLabelText(/Dark/i)
173+
await user.click(darkRadio)
174+
175+
// Theme should be applied immediately to document
176+
expect(document.documentElement.classList.contains("dark")).toBe(true)
177+
expect(document.documentElement.classList.contains("light")).toBe(false)
178+
})
179+
})
180+
181+
describe("System Theme Resolution", () => {
182+
it("shows resolved theme when system is selected", () => {
183+
renderWithTheme(<AppearanceSettingsPanel />)
184+
185+
// Default is system, and our mock says light
186+
expect(
187+
screen.getByText(/Currently following your system preference/i)
188+
).toBeInTheDocument()
189+
expect(screen.getByText("Light", { exact: false })).toBeInTheDocument()
190+
})
191+
192+
it("displays resolved theme next to system option label", () => {
193+
renderWithTheme(<AppearanceSettingsPanel />)
194+
195+
// Find the system option label
196+
const systemLabel = screen.getByText("System")
197+
const parentElement = systemLabel.closest("div")
198+
199+
expect(parentElement).toHaveTextContent("(Light)")
200+
})
201+
202+
it("updates resolved theme when system preference changes", () => {
203+
// Start with light system preference
204+
window.matchMedia = vi.fn().mockImplementation((query) => {
205+
const listeners: Array<(e: MediaQueryListEvent) => void> = []
206+
return {
207+
matches: query === "(prefers-color-scheme: dark)" ? false : true,
208+
media: query,
209+
onchange: null,
210+
addEventListener: (_: string, listener: (e: MediaQueryListEvent) => void) => {
211+
listeners.push(listener)
212+
},
213+
removeEventListener: vi.fn(),
214+
dispatchEvent: (event: MediaQueryListEvent) => {
215+
listeners.forEach((listener) => listener(event))
216+
return true
217+
},
218+
}
219+
})
220+
221+
renderWithTheme(<AppearanceSettingsPanel />)
222+
223+
// Initially shows Light
224+
expect(screen.getByText("Light", { exact: false })).toBeInTheDocument()
225+
226+
// Simulate system preference change to dark
227+
const mql = window.matchMedia("(prefers-color-scheme: dark)")
228+
const event = new Event("change") as MediaQueryListEvent
229+
Object.defineProperty(event, "matches", { value: true })
230+
mql.dispatchEvent(event)
231+
232+
// Should update to Dark
233+
// Note: This is a simplified test; real implementation depends on ThemeProvider's listeners
234+
})
235+
236+
it("hides system preference message when not on system theme", async () => {
237+
const user = userEvent.setup()
238+
renderWithTheme(<AppearanceSettingsPanel />)
239+
240+
// Initially on system theme
241+
expect(
242+
screen.getByText(/Currently following your system preference/i)
243+
).toBeInTheDocument()
244+
245+
// Switch to light
246+
const lightRadio = screen.getByLabelText(/Light/i)
247+
await user.click(lightRadio)
248+
249+
// Message should be gone
250+
expect(
251+
screen.queryByText(/Currently following your system preference/i)
252+
).not.toBeInTheDocument()
253+
})
254+
})
255+
256+
describe("Keyboard Accessibility", () => {
257+
it("radio group is keyboard navigable", async () => {
258+
const user = userEvent.setup()
259+
renderWithTheme(<AppearanceSettingsPanel />)
260+
261+
const lightRadio = screen.getByLabelText(/Light/i)
262+
263+
// Tab to first radio
264+
await user.tab()
265+
expect(lightRadio).toHaveFocus()
266+
})
267+
268+
it("has accessible radio group label", () => {
269+
renderWithTheme(<AppearanceSettingsPanel />)
270+
271+
const radioGroup = screen.getByRole("radiogroup", {
272+
name: /Theme selection/i,
273+
})
274+
expect(radioGroup).toBeInTheDocument()
275+
})
276+
277+
it("each radio has associated label", () => {
278+
renderWithTheme(<AppearanceSettingsPanel />)
279+
280+
const lightRadio = screen.getByRole("radio", { name: /Light/i })
281+
const darkRadio = screen.getByRole("radio", { name: /Dark/i })
282+
const systemRadio = screen.getByRole("radio", { name: /System/i })
283+
284+
expect(lightRadio).toBeInTheDocument()
285+
expect(darkRadio).toBeInTheDocument()
286+
expect(systemRadio).toBeInTheDocument()
287+
})
288+
})
289+
290+
describe("Visual Styling", () => {
291+
it("highlights selected option", async () => {
292+
const user = userEvent.setup()
293+
const { container } = renderWithTheme(<AppearanceSettingsPanel />)
294+
295+
const lightLabel = screen.getByLabelText(/Light/i).closest("label")
296+
await user.click(screen.getByLabelText(/Light/i))
297+
298+
expect(lightLabel).toHaveClass("border-primary")
299+
})
300+
301+
it("applies custom className", () => {
302+
const { container } = renderWithTheme(
303+
<AppearanceSettingsPanel className="custom-class" />
304+
)
305+
306+
const panel = container.querySelector('[data-slot="appearance-settings-panel"]')
307+
expect(panel).toHaveClass("custom-class")
308+
})
309+
310+
it("forwards additional props", () => {
311+
renderWithTheme(
312+
<AppearanceSettingsPanel data-testid="appearance-panel" />
313+
)
314+
315+
expect(screen.getByTestId("appearance-panel")).toBeInTheDocument()
316+
})
317+
})
318+
319+
describe("Accessibility", () => {
320+
it("passes axe accessibility tests", async () => {
321+
const { container } = renderWithTheme(<AppearanceSettingsPanel />)
322+
expect(await axe(container)).toHaveNoViolations()
323+
})
324+
325+
it("preview cards are hidden from screen readers", () => {
326+
const { container } = renderWithTheme(<AppearanceSettingsPanel />)
327+
const previews = container.querySelectorAll('[aria-hidden="true"]')
328+
329+
previews.forEach((preview) => {
330+
expect(preview).toHaveAttribute("aria-hidden", "true")
331+
})
332+
})
333+
})
334+
})

0 commit comments

Comments
 (0)