diff --git a/frontend/package.json b/frontend/package.json index 3aecfc19..ab71dd90 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -119,6 +119,7 @@ "eslint-plugin-testing-library": "^7.0.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", + "mock-match-media": "^0.4.3", "msw": "1.3.5", "postcss": "8.4.31", "prettier": "2.2.1", diff --git a/frontend/src/components/App/recoil/atoms.ts b/frontend/src/components/App/recoil/atoms.ts index 6d68629e..2a9e59b0 100644 --- a/frontend/src/components/App/recoil/atoms.ts +++ b/frontend/src/components/App/recoil/atoms.ts @@ -4,6 +4,9 @@ export const localStorageEffect = (key: string): AtomEffect => ({ setSe const savedValue = localStorage.getItem(key); if (savedValue != null) { setSelf(savedValue === 'true'); + } else { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + setSelf(mediaQuery.matches); } onSet((newValue, _, isReset) => { diff --git a/frontend/src/components/NavBar/RightMenu.test.tsx b/frontend/src/components/NavBar/RightMenu.test.tsx index bb4f631f..a9c56ddc 100644 --- a/frontend/src/components/NavBar/RightMenu.test.tsx +++ b/frontend/src/components/NavBar/RightMenu.test.tsx @@ -1,8 +1,11 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-return */ -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { RecoilRoot } from 'recoil'; +import { setMedia } from 'mock-match-media'; import customRender from '../../test/custom-render'; import Support from '../Support'; import RightMenu, { Props } from './RightMenu'; @@ -159,3 +162,89 @@ it('the the right drawer display for news button and hide news button', async () await user.click(hideBtn); }); + +describe('Dark Mode', () => { + it('respects (prefers-color-scheme: dark) when preference unset', () => { + setMedia({ + 'prefers-color-scheme': 'dark', + }); + localStorage.clear(); + render( + + + false} + > + + + ); + expect(screen.getByTestId('isDarkModeSwitch')).not.toBeChecked(); + }); + + it('respects (prefers-color-scheme: light) when preference unset', () => { + setMedia({ + 'prefers-color-scheme': 'light', + }); + localStorage.clear(); + render( + + + false} + > + + + ); + expect(screen.getByTestId('isDarkModeSwitch')).toBeChecked(); + }); + + it('gives precedence to stored preference over prefers-color-scheme', () => { + setMedia({ + 'prefers-color-scheme': 'dark', + }); + localStorage.setItem('isDarkMode', 'false'); + render( + + + false} + > + + + ); + expect(screen.getByTestId('isDarkModeSwitch')).toBeChecked(); + }); + + it('stores preference when selected', async () => { + setMedia({}); + localStorage.clear(); + render( + + + false} + > + + + ); + const isDarkModeSwitch = await screen.findByTestId('isDarkModeSwitch'); + expect(isDarkModeSwitch).not.toBeChecked(); + waitFor(() => { + fireEvent.click(isDarkModeSwitch); + }); + expect(await screen.findByTestId('isDarkModeSwitch')).toBeChecked(); + expect(localStorage.getItem('isDarkMode')).toBe('false'); + }); +}); diff --git a/frontend/src/components/NavBar/RightMenu.tsx b/frontend/src/components/NavBar/RightMenu.tsx index 35d479ab..6a723c41 100644 --- a/frontend/src/components/NavBar/RightMenu.tsx +++ b/frontend/src/components/NavBar/RightMenu.tsx @@ -225,6 +225,7 @@ const RightMenu: React.FC> = ({ unCheckedChildren="Dark" onChange={(checked) => setIsDarkMode(!checked)} defaultChecked={!isDarkMode} + data-testid="isDarkModeSwitch" > Theme diff --git a/frontend/src/setupTests.ts b/frontend/src/setupTests.ts index 6e5be124..8e4c4b06 100644 --- a/frontend/src/setupTests.ts +++ b/frontend/src/setupTests.ts @@ -12,6 +12,7 @@ import { sessionStorageMock, } from './test/jestTestFunctions'; import 'cross-fetch/polyfill'; +import 'mock-match-media/jest-setup'; jest.setTimeout(60000); @@ -20,19 +21,6 @@ const location = JSON.stringify(window.location); Object.defineProperty(window, 'localStorage', { value: sessionStorageMock }); Object.defineProperty(window, 'METAGRID', { value: mockConfig }); -Object.defineProperty(window, 'matchMedia', { - writable: true, - value: jest.fn().mockImplementation((query: string) => ({ - matches: false, - media: query, - onchange: null, - addListener: jest.fn(), // deprecated - removeListener: jest.fn(), // deprecated - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - dispatchEvent: jest.fn(), - })), -}); beforeAll(() => { server.listen(); diff --git a/frontend/yarn.lock b/frontend/yarn.lock index e6a79675..b5e11c19 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2321,6 +2321,11 @@ crypto-js@^4.0.0: resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631" integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q== +css-mediaquery@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/css-mediaquery/-/css-mediaquery-0.1.2.tgz#6a2c37344928618631c54bd33cedd301da18bea0" + integrity sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q== + css.escape@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb"