diff --git a/package-lock.json b/package-lock.json index daef1e3..3e38f4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@emotion/cache": "^11.13.1", "@emotion/styled": "^11.13.0", + "@fontsource/roboto": "^5.1.0", "@mui/material": "^6.1.0", "@mui/material-nextjs": "^6.0.0", "@next/env": "^14.2.7", @@ -2224,6 +2225,12 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fontsource/roboto": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.1.0.tgz", + "integrity": "sha512-cFRRC1s6RqPygeZ8Uw/acwVHqih8Czjt6Q0MwoUoDe9U3m4dH1HmNDRBZyqlMSFwgNAUKgFImncKdmDHyKpwdg==", + "license": "Apache-2.0" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", diff --git a/package.json b/package.json index 6f5ac71..71c6654 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dependencies": { "@emotion/cache": "^11.13.1", "@emotion/styled": "^11.13.0", + "@fontsource/roboto": "^5.1.0", "@mui/material": "^6.1.0", "@mui/material-nextjs": "^6.0.0", "@next/env": "^14.2.7", diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4bebf97..f1da86e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,15 +1,24 @@ -import React, { ReactNode } from 'react'; +import '@fontsource/roboto/300.css'; +import '@fontsource/roboto/400.css'; +import '@fontsource/roboto/500.css'; +import '@fontsource/roboto/700.css'; import { AppRouterCacheProvider } from '@mui/material-nextjs/v13-appRouter'; +import { CssBaseline } from '@mui/material'; +import React from 'react'; +import { ThemeProvider } from '../theme/ThemeContext'; -interface RootLayoutProps { - children: ReactNode; -} - -export default function RootLayout({ children }: RootLayoutProps) { +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { return ( - {children} + + + {children} + ); diff --git a/src/theme/ThemeContext.tsx b/src/theme/ThemeContext.tsx new file mode 100644 index 0000000..028c1b0 --- /dev/null +++ b/src/theme/ThemeContext.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { ThemeProvider as MuiThemeProvider, Theme } from '@mui/material/styles'; +import React, { createContext, useEffect, useMemo, useState } from 'react'; +import { darkTheme, lightTheme } from './theme'; + +interface ThemeContextType { + toggleTheme: () => void; + isDarkMode: boolean; +} + +export const ThemeContext = createContext({ + toggleTheme: () => {}, + isDarkMode: false, +}); + +export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [isDarkMode, setIsDarkMode] = useState(false); + + useEffect(() => { + const savedTheme = localStorage.getItem('theme'); + if (savedTheme) { + setIsDarkMode(savedTheme === 'dark'); + } + }, []); + + const toggleTheme = () => { + setIsDarkMode((prev) => { + const newMode = !prev; + localStorage.setItem('theme', newMode ? 'dark' : 'light'); + return newMode; + }); + }; + + const theme: Theme = useMemo( + () => (isDarkMode ? darkTheme : lightTheme), + [isDarkMode] + ); + + return ( + + {children} + + ); +}; diff --git a/src/theme/theme.ts b/src/theme/theme.ts new file mode 100644 index 0000000..1943e1d --- /dev/null +++ b/src/theme/theme.ts @@ -0,0 +1,114 @@ +import { createTheme } from '@mui/material/styles'; + +const commonSettings = { + typography: { + fontFamily: 'Roboto, Arial, sans-serif, ', + body1: { + color: 'var(--common-white_states-main, #FFF)', + fontFamily: 'Roboto', + fontSize: '16px', + fontStyle: 'normal', + fontWeight: 400, + lineHeight: '24px', + letterSpacing: '0.15px', + }, + body2: { + color: '#FFFFFF', + fontFamily: 'Roboto', + fontSize: '14px', + fontWeight: 400, + lineHeight: '20px', + letterSpacing: '0.15px', + }, + h1: { + color: 'var(--common-white_states-main, #FFF)', + fontFamily: 'Roboto', + fontSize: '30px', + fontStyle: 'normal', + fontWeight: 500, + lineHeight: '24px', + letterSpacing: '0.15px', + }, + h2: { + color: 'var(--common-white_states-main, #FFF)', + fontFamily: 'Roboto', + fontSize: '24px', + fontWeight: 500, + lineHeight: '32px', + letterSpacing: '0.1px', + }, + h3: { + color: 'var(--common-white_states-main, #FFF)', + fontFamily: 'Roboto', + fontSize: '20px', + fontWeight: 500, + lineHeight: '28px', + letterSpacing: '0.1px', + }, + subtitle1: { + color: '#D9D9D9', + fontFamily: 'Roboto', + fontSize: '16px', + fontWeight: 400, + lineHeight: '24px', + letterSpacing: '0.15px', + }, + subtitle2: { + color: '#8F8C8C', + fontFamily: 'Roboto', + fontSize: '14px', + fontWeight: 400, + lineHeight: '20px', + letterSpacing: '0.1px', + }, + caption: { + color: '#8F8C8C', + fontFamily: 'Roboto', + fontSize: '12px', + fontWeight: 400, + lineHeight: '16px', + letterSpacing: '0.4px', + }, + overline: { + color: '#8F8C8C', + fontFamily: 'Roboto', + fontSize: '10px', + fontWeight: 400, + lineHeight: '16px', + letterSpacing: '1.5px', + textTransform: 'uppercase' as const, + }, + }, +}; + +export const lightTheme = createTheme({ + ...commonSettings, + palette: { + mode: 'light', + primary: { main: '#282828' }, + secondary: { main: '#8F8C8C' }, + background: { default: '#D9D9D9', paper: '#fff' }, + text: { + primary: '#000000', + secondary: '#282828', + }, + }, +}); + +export const darkTheme = createTheme({ + ...commonSettings, + palette: { + mode: 'dark', + primary: { main: '#FFFFFF' }, + secondary: { main: '#8F8C8C' }, + background: { + default: '#000000', + paper: '#161616', + }, + text: { + primary: '#FFFFFF', + secondary: '#D9D9D9', + }, + divider: '#282828', + }, +}); diff --git a/tests/theme/theme.test.tsx b/tests/theme/theme.test.tsx new file mode 100644 index 0000000..b71573a --- /dev/null +++ b/tests/theme/theme.test.tsx @@ -0,0 +1,187 @@ +import '@testing-library/jest-dom'; +import React, { useContext } from 'react'; +import { ThemeContext, ThemeProvider } from '../../src/theme/ThemeContext'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { darkTheme, lightTheme } from '../../src/theme/theme'; +import { useTheme } from '@mui/material/styles'; + +const localStorageMock = (() => { + let store: { [key: string]: string } = {}; + return { + getItem: (key: string) => store[key] || null, + setItem: (key: string, value: string) => { + store[key] = value; + }, + clear: () => { + store = {}; + }, + }; +})(); + +Object.defineProperty(window, 'localStorage', { + value: localStorageMock, +}); + +const TestThemeComponent = () => { + const theme = useTheme(); + const { toggleTheme, isDarkMode } = useContext(ThemeContext); + + return ( +
+

{isDarkMode ? 'Dark Mode' : 'Light Mode'}

+

{theme.palette.primary.main}

+

{theme.palette.background.default}

+

{theme.palette.text.primary}

+ +
+ ); +}; + +describe('ThemeProvider functionality', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + const renderWithThemeProvider = () => + render( + + + + ); + + describe('Initial Render', () => { + it('renders in light mode by default when no theme is stored', () => { + renderWithThemeProvider(); + expect(screen.getByTestId('theme-mode')).toHaveTextContent('Light Mode'); + expect(localStorage.getItem('theme')).toBeNull(); + }); + + it('loads dark theme from localStorage on initial render', async () => { + localStorage.setItem('theme', 'dark'); + await act(async () => { + renderWithThemeProvider(); + }); + expect(screen.getByTestId('theme-mode')).toHaveTextContent('Dark Mode'); + }); + + it('loads light theme from localStorage on initial render', async () => { + localStorage.setItem('theme', 'light'); + await act(async () => { + renderWithThemeProvider(); + }); + expect(screen.getByTestId('theme-mode')).toHaveTextContent('Light Mode'); + }); + }); + + describe('Theme Toggle Functionality', () => { + it('toggles from light to dark mode', async () => { + renderWithThemeProvider(); + + await act(async () => { + fireEvent.click(screen.getByText('Toggle Theme')); + }); + + expect(screen.getByTestId('theme-mode')).toHaveTextContent('Dark Mode'); + expect(localStorage.getItem('theme')).toBe('dark'); + }); + + it('toggles from dark to light mode', async () => { + localStorage.setItem('theme', 'dark'); + renderWithThemeProvider(); + + await act(async () => { + fireEvent.click(screen.getByText('Toggle Theme')); + }); + + expect(screen.getByTestId('theme-mode')).toHaveTextContent('Light Mode'); + expect(localStorage.getItem('theme')).toBe('light'); + }); + + it('persists theme choice across multiple toggles', async () => { + renderWithThemeProvider(); + + await act(async () => { + fireEvent.click(screen.getByText('Toggle Theme')); + }); + expect(localStorage.getItem('theme')).toBe('dark'); + + await act(async () => { + fireEvent.click(screen.getByText('Toggle Theme')); + }); + expect(localStorage.getItem('theme')).toBe('light'); + }); + }); + + describe('Theme Properties', () => { + it('applies correct light theme properties', () => { + renderWithThemeProvider(); + + expect(screen.getByTestId('primary-color')).toHaveTextContent( + lightTheme.palette.primary.main + ); + expect(screen.getByTestId('background-color')).toHaveTextContent( + lightTheme.palette.background.default + ); + expect(screen.getByTestId('text-color')).toHaveTextContent( + lightTheme.palette.text.primary + ); + }); + + it('applies correct dark theme properties', async () => { + renderWithThemeProvider(); + + await act(async () => { + fireEvent.click(screen.getByText('Toggle Theme')); + }); + + expect(screen.getByTestId('primary-color')).toHaveTextContent( + darkTheme.palette.primary.main + ); + expect(screen.getByTestId('background-color')).toHaveTextContent( + darkTheme.palette.background.default + ); + expect(screen.getByTestId('text-color')).toHaveTextContent( + darkTheme.palette.text.primary + ); + }); + + it('maintains theme properties after multiple toggles', async () => { + renderWithThemeProvider(); + + expect(screen.getByTestId('primary-color')).toHaveTextContent( + lightTheme.palette.primary.main + ); + + await act(async () => { + fireEvent.click(screen.getByText('Toggle Theme')); + }); + expect(screen.getByTestId('primary-color')).toHaveTextContent( + darkTheme.palette.primary.main + ); + + await act(async () => { + fireEvent.click(screen.getByText('Toggle Theme')); + }); + expect(screen.getByTestId('primary-color')).toHaveTextContent( + lightTheme.palette.primary.main + ); + }); + }); + + describe('Theme Context', () => { + it('provides isDarkMode value through context', () => { + renderWithThemeProvider(); + expect(screen.getByTestId('theme-mode')).toHaveTextContent('Light Mode'); + }); + + it('updates context value when theme changes', async () => { + renderWithThemeProvider(); + + await act(async () => { + fireEvent.click(screen.getByText('Toggle Theme')); + }); + + expect(screen.getByTestId('theme-mode')).toHaveTextContent('Dark Mode'); + }); + }); +});