Skip to content

Commit 8a72941

Browse files
committed
πŸ› Fix dark mode theme sync for snapshots
- Apply theme to documentElement (.dark) and keep system theme in sync\n- Initialize preferences early in src/main.tsx\n- Update dark-mode Playwright baseline screenshots
1 parent f903050 commit 8a72941

7 files changed

Lines changed: 142 additions & 1 deletion

File tree

588 Bytes
Loading
384 Bytes
Loading
703 Bytes
Loading
1010 Bytes
Loading

β€Žsrc/main.tsxβ€Ž

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,14 @@ import i18n from './i18n'
77
import { router } from './routes'
88
import { ServiceProvider } from './services'
99
import { MigrationProvider } from './contexts/MigrationContext'
10+
import { preferencesActions } from './stores/preferences'
1011
import './styles/globals.css'
1112

1213
const rootElement = document.getElementById('root')
1314
if (!rootElement) throw new Error('Root element not found')
1415

16+
preferencesActions.initialize()
17+
1518
createRoot(rootElement).render(
1619
<StrictMode>
1720
<ServiceProvider>

β€Žsrc/stores/preferences.test.tsβ€Ž

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,55 @@ const localStorageMock = (() => {
4343

4444
Object.defineProperty(global, 'localStorage', { value: localStorageMock })
4545

46+
function mockMatchMedia(initialMatches: boolean) {
47+
const listeners = new Set<(event: MediaQueryListEvent) => void>()
48+
49+
const mql = {
50+
matches: initialMatches,
51+
media: '(prefers-color-scheme: dark)',
52+
onchange: null,
53+
addEventListener: (type: string, listener: (event: MediaQueryListEvent) => void) => {
54+
if (type === 'change') listeners.add(listener)
55+
},
56+
removeEventListener: (type: string, listener: (event: MediaQueryListEvent) => void) => {
57+
if (type === 'change') listeners.delete(listener)
58+
},
59+
addListener: (listener: (event: MediaQueryListEvent) => void) => {
60+
listeners.add(listener)
61+
},
62+
removeListener: (listener: (event: MediaQueryListEvent) => void) => {
63+
listeners.delete(listener)
64+
},
65+
dispatchEvent: () => true,
66+
}
67+
68+
Object.defineProperty(window, 'matchMedia', {
69+
configurable: true,
70+
value: vi.fn(() => mql as unknown as MediaQueryList),
71+
})
72+
73+
return {
74+
setMatches(next: boolean) {
75+
mql.matches = next
76+
const event = { matches: next } as MediaQueryListEvent
77+
for (const listener of listeners) listener(event)
78+
},
79+
}
80+
}
81+
4682
describe('preferencesStore', () => {
4783
beforeEach(() => {
84+
vi.clearAllMocks()
85+
vi.unstubAllGlobals()
86+
localStorageMock.clear()
87+
document.documentElement.classList.remove('dark')
88+
// Ensure any previous system-theme listener is detached (initialize() handles cleanup).
89+
preferencesStore.setState(() => ({
90+
language: 'zh-CN',
91+
currency: 'USD',
92+
theme: 'light',
93+
}))
94+
preferencesActions.initialize()
4895
vi.clearAllMocks()
4996
localStorageMock.clear()
5097
// Reset store state
@@ -122,6 +169,14 @@ describe('preferencesStore', () => {
122169
const savedValue = localStorageMock.setItem.mock.calls[0]![1]
123170
expect(JSON.parse(savedValue).theme).toBe('light')
124171
})
172+
173+
it('syncs document dark class', () => {
174+
preferencesActions.setTheme('dark')
175+
expect(document.documentElement.classList.contains('dark')).toBe(true)
176+
177+
preferencesActions.setTheme('light')
178+
expect(document.documentElement.classList.contains('dark')).toBe(false)
179+
})
125180
})
126181

127182
describe('initialize', () => {
@@ -150,5 +205,21 @@ describe('preferencesStore', () => {
150205
expect(vi.mocked(i18n.changeLanguage)).not.toHaveBeenCalled()
151206
expect(document.documentElement.dir).toBe('ltr')
152207
})
208+
209+
it('applies system theme and tracks prefers-color-scheme changes', () => {
210+
const media = mockMatchMedia(true)
211+
212+
preferencesStore.setState(() => ({
213+
language: 'zh-CN',
214+
currency: 'USD',
215+
theme: 'system',
216+
}))
217+
218+
preferencesActions.initialize()
219+
expect(document.documentElement.classList.contains('dark')).toBe(true)
220+
221+
media.setMatches(false)
222+
expect(document.documentElement.classList.contains('dark')).toBe(false)
223+
})
153224
})
154225
})

β€Žsrc/stores/preferences.tsβ€Ž

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import { type LanguageCode, languages, defaultLanguage, getLanguageDirection } f
66
// Storage key
77
const PREFERENCES_KEY = 'bfmpay_preferences'
88

9+
type ThemePreference = 'light' | 'dark' | 'system'
10+
type ResolvedTheme = Exclude<ThemePreference, 'system'>
11+
912
// Currency types
1013
export type CurrencyCode = 'USD' | 'CNY' | 'EUR' | 'JPY' | 'KRW'
1114

@@ -21,7 +24,7 @@ export const currencies: Record<CurrencyCode, { symbol: string; name: string }>
2124
export interface PreferencesState {
2225
language: LanguageCode
2326
currency: CurrencyCode
24-
theme: 'light' | 'dark' | 'system'
27+
theme: ThemePreference
2528
}
2629

2730
// Default state
@@ -31,6 +34,68 @@ const defaultState: PreferencesState = {
3134
theme: 'system',
3235
}
3336

37+
let systemThemeMediaQuery: MediaQueryList | null = null
38+
let systemThemeListener: ((event: MediaQueryListEvent) => void) | null = null
39+
40+
function getSystemThemeMediaQuery(): MediaQueryList | null {
41+
if (typeof window === 'undefined') return null
42+
if (typeof window.matchMedia !== 'function') return null
43+
return window.matchMedia('(prefers-color-scheme: dark)')
44+
}
45+
46+
function resolveTheme(theme: ThemePreference): ResolvedTheme {
47+
if (theme === 'dark' || theme === 'light') return theme
48+
const mediaQuery = getSystemThemeMediaQuery()
49+
return mediaQuery?.matches ? 'dark' : 'light'
50+
}
51+
52+
function applyThemeToDocument(theme: ThemePreference): void {
53+
if (typeof document === 'undefined') return
54+
const resolved = resolveTheme(theme)
55+
document.documentElement.classList.toggle('dark', resolved === 'dark')
56+
}
57+
58+
function stopSystemThemeSync(): void {
59+
if (!systemThemeMediaQuery || !systemThemeListener) return
60+
61+
if (typeof systemThemeMediaQuery.removeEventListener === 'function') {
62+
systemThemeMediaQuery.removeEventListener('change', systemThemeListener)
63+
} else if (typeof systemThemeMediaQuery.removeListener === 'function') {
64+
systemThemeMediaQuery.removeListener(systemThemeListener)
65+
}
66+
67+
systemThemeMediaQuery = null
68+
systemThemeListener = null
69+
}
70+
71+
function startSystemThemeSync(): void {
72+
const mediaQuery = getSystemThemeMediaQuery()
73+
if (!mediaQuery) return
74+
75+
// Ensure idempotency: don't attach multiple listeners.
76+
if (systemThemeMediaQuery === mediaQuery && systemThemeListener) return
77+
78+
stopSystemThemeSync()
79+
systemThemeMediaQuery = mediaQuery
80+
systemThemeListener = (event) => {
81+
if (preferencesStore.state.theme !== 'system') return
82+
if (typeof document === 'undefined') return
83+
document.documentElement.classList.toggle('dark', event.matches)
84+
}
85+
86+
if (typeof mediaQuery.addEventListener === 'function') {
87+
mediaQuery.addEventListener('change', systemThemeListener)
88+
} else if (typeof mediaQuery.addListener === 'function') {
89+
mediaQuery.addListener(systemThemeListener)
90+
}
91+
}
92+
93+
function syncTheme(theme: ThemePreference): void {
94+
applyThemeToDocument(theme)
95+
if (theme === 'system') startSystemThemeSync()
96+
else stopSystemThemeSync()
97+
}
98+
3499
// Load from localStorage
35100
function loadFromStorage(): PreferencesState {
36101
try {
@@ -86,6 +151,7 @@ export const preferencesActions = {
86151
saveToStorage(newState)
87152
return newState
88153
})
154+
syncTheme(theme)
89155
},
90156

91157
/** εˆε§‹εŒ– - 同ζ­₯ i18n θ―­θ¨€ε’Œζ–‡ε­—ζ–Ήε‘ */
@@ -96,6 +162,7 @@ export const preferencesActions = {
96162
}
97163
// Set document direction for RTL support
98164
document.documentElement.dir = getLanguageDirection(state.language)
165+
syncTheme(state.theme)
99166
},
100167
}
101168

0 commit comments

Comments
Β (0)