@@ -9,19 +9,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
99import { ThemeProvider , useTheme } from "./theme" ;
1010
1111function ThemeProbe ( ) {
12- const { theme, setTheme , toggleTheme } = useTheme ( ) ;
12+ const { theme, preference , setPreference } = useTheme ( ) ;
1313
1414 return (
1515 < >
16- < div data-testid = "theme-value" > { theme } </ div >
17- < button onClick = { ( ) => setTheme ( "dark" ) } type = "button" >
18- set-dark
19- </ button >
20- < button onClick = { ( ) => setTheme ( "light" ) } type = "button" >
16+ < div data-testid = "resolved-theme" > { theme } </ div >
17+ < div data-testid = "preference" > { preference } </ div >
18+ < button onClick = { ( ) => setPreference ( "light" ) } type = "button" >
2119 set-light
2220 </ button >
23- < button onClick = { toggleTheme } type = "button" >
24- toggle-theme
21+ < button onClick = { ( ) => setPreference ( "dark" ) } type = "button" >
22+ set-dark
23+ </ button >
24+ < button onClick = { ( ) => setPreference ( "system" ) } type = "button" >
25+ set-system
2526 </ button >
2627 </ >
2728 ) ;
@@ -32,6 +33,19 @@ describe("ThemeProvider", () => {
3233 let originalMatchMedia : typeof window . matchMedia | undefined ;
3334 let originalLocalStorage : Storage ;
3435
36+ function createMediaQueryList ( matches : boolean ) : MediaQueryList {
37+ return {
38+ matches,
39+ media : "(prefers-color-scheme: dark)" ,
40+ onchange : null ,
41+ addEventListener : vi . fn ( ) ,
42+ removeEventListener : vi . fn ( ) ,
43+ addListener : vi . fn ( ) ,
44+ removeListener : vi . fn ( ) ,
45+ dispatchEvent : vi . fn ( ) ,
46+ } as unknown as MediaQueryList ;
47+ }
48+
3549 const localStorageMock : Storage = {
3650 getItem : ( key : string ) => storage . get ( key ) ?? null ,
3751 setItem : ( key : string , value : string ) => {
@@ -56,13 +70,30 @@ describe("ThemeProvider", () => {
5670 originalMatchMedia = window . matchMedia ;
5771 originalLocalStorage = window . localStorage ;
5872
73+ Object . defineProperty ( window , "matchMedia" , {
74+ configurable : true ,
75+ writable : true ,
76+ value : vi . fn ( ) . mockReturnValue ( createMediaQueryList ( false ) ) ,
77+ } ) ;
78+
5979 Object . defineProperty ( window , "localStorage" , {
6080 configurable : true ,
6181 writable : true ,
6282 value : localStorageMock ,
6383 } ) ;
84+
6485 window . localStorage . clear ( ) ;
6586 document . documentElement . classList . remove ( "dark" ) ;
87+
88+ const existingMeta = document . querySelector ( 'meta[name="theme-color"]' ) ;
89+ if ( ! existingMeta ) {
90+ const meta = document . createElement ( "meta" ) ;
91+ meta . setAttribute ( "name" , "theme-color" ) ;
92+ meta . setAttribute ( "content" , "#fafafa" ) ;
93+ document . head . appendChild ( meta ) ;
94+ } else {
95+ existingMeta . setAttribute ( "content" , "#fafafa" ) ;
96+ }
6697 } ) ;
6798
6899 afterEach ( ( ) => {
@@ -85,89 +116,103 @@ describe("ThemeProvider", () => {
85116 vi . restoreAllMocks ( ) ;
86117 } ) ;
87118
88- it ( "uses persisted localStorage theme on first render" , ( ) => {
89- window . localStorage . setItem ( "app-theme" , "dark" ) ;
119+ it ( "defaults to system preference when nothing is stored" , ( ) => {
120+ Object . defineProperty ( window , "matchMedia" , {
121+ configurable : true ,
122+ writable : true ,
123+ value : vi . fn ( ) . mockReturnValue ( createMediaQueryList ( true ) ) ,
124+ } ) ;
90125
91126 render (
92127 < ThemeProvider >
93128 < ThemeProbe />
94129 </ ThemeProvider > ,
95130 ) ;
96131
97- expect ( screen . getByTestId ( "theme-value" ) ) . toHaveTextContent ( "dark" ) ;
98- expect ( document . documentElement . classList . contains ( "dark" ) ) . toBe ( true ) ;
132+ expect ( screen . getByTestId ( "preference" ) ) . toHaveTextContent ( "system" ) ;
133+ expect ( screen . getByTestId ( "resolved-theme" ) ) . toHaveTextContent ( "dark" ) ;
134+ expect ( window . localStorage . getItem ( "app-theme-preference" ) ) . toBe ( "system" ) ;
99135 } ) ;
100136
101- it ( "falls back to system preference when no theme is persisted" , ( ) => {
102- Object . defineProperty ( window , "matchMedia" , {
103- configurable : true ,
104- writable : true ,
105- value : vi . fn ( ) . mockImplementation ( ( query : string ) => ( {
106- matches : query === "(prefers-color-scheme: dark)" ,
107- media : query ,
108- onchange : null ,
109- addEventListener : vi . fn ( ) ,
110- removeEventListener : vi . fn ( ) ,
111- dispatchEvent : vi . fn ( ) ,
112- } ) ) ,
113- } ) ;
137+ it ( "uses persisted explicit preference" , ( ) => {
138+ window . localStorage . setItem ( "app-theme-preference" , "dark" ) ;
114139
115140 render (
116141 < ThemeProvider >
117142 < ThemeProbe />
118143 </ ThemeProvider > ,
119144 ) ;
120145
121- expect ( screen . getByTestId ( "theme-value" ) ) . toHaveTextContent ( "dark" ) ;
146+ expect ( screen . getByTestId ( "preference" ) ) . toHaveTextContent ( "dark" ) ;
147+ expect ( screen . getByTestId ( "resolved-theme" ) ) . toHaveTextContent ( "dark" ) ;
122148 expect ( document . documentElement . classList . contains ( "dark" ) ) . toBe ( true ) ;
149+ expect (
150+ document
151+ . querySelector ( 'meta[name="theme-color"]' )
152+ ?. getAttribute ( "content" ) ,
153+ ) . toBe ( "#0f0f0f" ) ;
123154 } ) ;
124155
125- it ( "writes updates to localStorage and keeps DOM class in sync" , ( ) => {
156+ it ( "migrates legacy app-theme values" , ( ) => {
157+ window . localStorage . setItem ( "app-theme" , "light" ) ;
158+
126159 render (
127160 < ThemeProvider >
128161 < ThemeProbe />
129162 </ ThemeProvider > ,
130163 ) ;
131164
132- fireEvent . click ( screen . getByRole ( "button" , { name : "set-dark" } ) ) ;
133- expect ( window . localStorage . getItem ( "app-theme" ) ) . toBe ( "dark" ) ;
134- expect ( document . documentElement . classList . contains ( "dark" ) ) . toBe ( true ) ;
135-
136- fireEvent . click ( screen . getByRole ( "button" , { name : "set-light" } ) ) ;
137- expect ( window . localStorage . getItem ( "app-theme" ) ) . toBe ( "light" ) ;
138- expect ( document . documentElement . classList . contains ( "dark" ) ) . toBe ( false ) ;
165+ expect ( screen . getByTestId ( "preference" ) ) . toHaveTextContent ( "light" ) ;
166+ expect ( window . localStorage . getItem ( "app-theme-preference" ) ) . toBe ( "light" ) ;
139167 } ) ;
140168
141- it ( "reacts to theme updates from storage events" , async ( ) => {
169+ it ( "updates preference and stores only the preference" , ( ) => {
142170 render (
143171 < ThemeProvider >
144172 < ThemeProbe />
145173 </ ThemeProvider > ,
146174 ) ;
147175
148- const event = new Event ( "storage" ) ;
149- Object . defineProperty ( event , "key" , { value : "app-theme" } ) ;
150- Object . defineProperty ( event , "newValue" , { value : "dark" } ) ;
151- window . dispatchEvent ( event ) ;
176+ fireEvent . click ( screen . getByRole ( "button" , { name : "set-dark" } ) ) ;
177+ expect ( screen . getByTestId ( "preference" ) ) . toHaveTextContent ( "dark" ) ;
178+ expect ( window . localStorage . getItem ( "app-theme-preference" ) ) . toBe ( "dark" ) ;
179+ expect (
180+ document
181+ . querySelector ( 'meta[name="theme-color"]' )
182+ ?. getAttribute ( "content" ) ,
183+ ) . toBe ( "#0f0f0f" ) ;
152184
153- await waitFor ( ( ) => {
154- expect ( screen . getByTestId ( "theme-value" ) ) . toHaveTextContent ( "dark" ) ;
155- expect ( document . documentElement . classList . contains ( "dark" ) ) . toBe ( true ) ;
156- } ) ;
185+ fireEvent . click ( screen . getByRole ( "button" , { name : "set-light" } ) ) ;
186+ expect ( screen . getByTestId ( "preference" ) ) . toHaveTextContent ( "light" ) ;
187+ expect ( window . localStorage . getItem ( "app-theme-preference" ) ) . toBe ( "light" ) ;
188+ expect (
189+ document
190+ . querySelector ( 'meta[name="theme-color"]' )
191+ ?. getAttribute ( "content" ) ,
192+ ) . toBe ( "#fafafa" ) ;
193+
194+ fireEvent . click ( screen . getByRole ( "button" , { name : "set-system" } ) ) ;
195+ expect ( screen . getByTestId ( "preference" ) ) . toHaveTextContent ( "system" ) ;
196+ expect ( window . localStorage . getItem ( "app-theme-preference" ) ) . toBe ( "system" ) ;
157197 } ) ;
158198
159- it ( "falls back to system preference when storage key is cleared" , async ( ) => {
199+ it ( "reacts to OS theme changes while preference is system" , async ( ) => {
200+ const listeners = new Set < ( event : MediaQueryListEvent ) => void > ( ) ;
201+
160202 Object . defineProperty ( window , "matchMedia" , {
161203 configurable : true ,
162204 writable : true ,
163- value : vi . fn ( ) . mockImplementation ( ( query : string ) => ( {
164- matches : query === "(prefers-color-scheme: dark)" ,
165- media : query ,
166- onchange : null ,
167- addEventListener : vi . fn ( ) ,
168- removeEventListener : vi . fn ( ) ,
169- dispatchEvent : vi . fn ( ) ,
170- } ) ) ,
205+ value : vi . fn ( ) . mockReturnValue ( {
206+ ...createMediaQueryList ( false ) ,
207+ addEventListener : (
208+ _ : string ,
209+ cb : ( event : MediaQueryListEvent ) => void ,
210+ ) => listeners . add ( cb ) ,
211+ removeEventListener : (
212+ _ : string ,
213+ cb : ( event : MediaQueryListEvent ) => void ,
214+ ) => listeners . delete ( cb ) ,
215+ } ) ,
171216 } ) ;
172217
173218 render (
@@ -176,65 +221,46 @@ describe("ThemeProvider", () => {
176221 </ ThemeProvider > ,
177222 ) ;
178223
179- fireEvent . click ( screen . getByRole ( "button" , { name : "set-light" } ) ) ;
180- window . localStorage . removeItem ( "app-theme" ) ;
224+ expect ( screen . getByTestId ( "resolved-theme" ) ) . toHaveTextContent ( "light" ) ;
181225
182- const event = new Event ( "storage" ) ;
183- Object . defineProperty ( event , "key" , { value : "app-theme" } ) ;
184- Object . defineProperty ( event , "newValue" , { value : null } ) ;
185- window . dispatchEvent ( event ) ;
226+ listeners . forEach ( ( listener ) =>
227+ listener ( { matches : true } as MediaQueryListEvent ) ,
228+ ) ;
186229
187230 await waitFor ( ( ) => {
188- expect ( screen . getByTestId ( "theme-value" ) ) . toHaveTextContent ( "dark" ) ;
231+ expect ( screen . getByTestId ( "resolved-theme" ) ) . toHaveTextContent ( "dark" ) ;
232+ expect ( document . documentElement . classList . contains ( "dark" ) ) . toBe ( true ) ;
233+ expect (
234+ document
235+ . querySelector ( 'meta[name="theme-color"]' )
236+ ?. getAttribute ( "content" ) ,
237+ ) . toBe ( "#0f0f0f" ) ;
189238 } ) ;
190239 } ) ;
191240
192- it ( "recovers when storage read throws" , ( ) => {
193- Object . defineProperty ( window , "localStorage" , {
194- configurable : true ,
195- writable : true ,
196- value : {
197- ...localStorageMock ,
198- getItem : ( ) => {
199- throw new Error ( "read denied" ) ;
200- } ,
201- } as Storage ,
202- } ) ;
203-
204- Object . defineProperty ( window , "matchMedia" , {
205- configurable : true ,
206- writable : true ,
207- value : vi . fn ( ) . mockImplementation ( ( query : string ) => ( {
208- matches : query === "(prefers-color-scheme: dark)" ,
209- media : query ,
210- onchange : null ,
211- addEventListener : vi . fn ( ) ,
212- removeEventListener : vi . fn ( ) ,
213- dispatchEvent : vi . fn ( ) ,
214- } ) ) ,
215- } ) ;
216-
241+ it ( "syncs preference across tabs via storage events" , async ( ) => {
217242 render (
218243 < ThemeProvider >
219244 < ThemeProbe />
220245 </ ThemeProvider > ,
221246 ) ;
222247
223- expect ( screen . getByTestId ( "theme-value" ) ) . toHaveTextContent ( "dark" ) ;
224- } ) ;
248+ const event = new Event ( "storage" ) ;
249+ Object . defineProperty ( event , "key" , { value : "app-theme-preference" } ) ;
250+ Object . defineProperty ( event , "newValue" , { value : "dark" } ) ;
251+ window . dispatchEvent ( event ) ;
225252
226- it ( "ignores storage write failures" , ( ) => {
227- const setItem = vi . fn ( ( ) => {
228- throw new Error ( "write denied ") ;
253+ await waitFor ( ( ) => {
254+ expect ( screen . getByTestId ( "preference" ) ) . toHaveTextContent ( "dark" ) ;
255+ expect ( screen . getByTestId ( "resolved-theme" ) ) . toHaveTextContent ( "dark ") ;
229256 } ) ;
257+ } ) ;
230258
231- Object . defineProperty ( window , "localStorage" , {
259+ it ( "does not overwrite preference with resolved system theme" , ( ) => {
260+ Object . defineProperty ( window , "matchMedia" , {
232261 configurable : true ,
233262 writable : true ,
234- value : {
235- ...localStorageMock ,
236- setItem,
237- } as Storage ,
263+ value : vi . fn ( ) . mockReturnValue ( createMediaQueryList ( true ) ) ,
238264 } ) ;
239265
240266 render (
@@ -243,9 +269,8 @@ describe("ThemeProvider", () => {
243269 </ ThemeProvider > ,
244270 ) ;
245271
246- fireEvent . click ( screen . getByRole ( "button" , { name : "set-dark" } ) ) ;
247- expect ( screen . getByTestId ( "theme-value" ) ) . toHaveTextContent ( "dark" ) ;
248- expect ( document . documentElement . classList . contains ( "dark" ) ) . toBe ( true ) ;
249- expect ( setItem ) . toHaveBeenCalled ( ) ;
272+ expect ( screen . getByTestId ( "preference" ) ) . toHaveTextContent ( "system" ) ;
273+ expect ( screen . getByTestId ( "resolved-theme" ) ) . toHaveTextContent ( "dark" ) ;
274+ expect ( window . localStorage . getItem ( "app-theme-preference" ) ) . toBe ( "system" ) ;
250275 } ) ;
251276} ) ;
0 commit comments