diff --git a/client/jest.setup.js b/client/jest.setup.js index ccc7ab57a7..c075aa4eb8 100644 --- a/client/jest.setup.js +++ b/client/jest.setup.js @@ -5,3 +5,35 @@ import 'regenerator-runtime/runtime'; // See: https://github.com/testing-library/jest-dom // eslint-disable-next-line import/no-extraneous-dependencies import '@testing-library/jest-dom'; + +// Mock matchMedia +window.matchMedia = jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // Deprecated + removeListener: jest.fn(), // Deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn() +})); + +// Mock localStorage +const localStorageMock = (function () { + let store = {}; + return { + getItem: jest.fn((key) => store[key] || null), + setItem: jest.fn((key, value) => { + store[key] = value.toString(); + }), + removeItem: jest.fn((key) => { + delete store[key]; + }), + clear: jest.fn(() => { + store = {}; + }) + }; +})(); +Object.defineProperty(window, 'localStorage', { + value: localStorageMock +}); diff --git a/client/modules/App/components/ThemeProvider.jsx b/client/modules/App/components/ThemeProvider.jsx index 8bbef6931e..b6d450099b 100644 --- a/client/modules/App/components/ThemeProvider.jsx +++ b/client/modules/App/components/ThemeProvider.jsx @@ -1,11 +1,62 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; -import { useSelector } from 'react-redux'; +import { useSelector, useDispatch } from 'react-redux'; import { ThemeProvider } from 'styled-components'; -import theme from '../../../theme'; +import theme, { Theme } from '../../../theme'; +import { setTheme } from '../../IDE/actions/preferences'; const Provider = ({ children }) => { const currentTheme = useSelector((state) => state.preferences.theme); + const dispatch = useDispatch(); + + // Detect system color scheme preference on initial load + useEffect(() => { + // Only apply system preference if the user hasn't explicitly set a theme + const userHasExplicitlySetTheme = + localStorage.getItem('has_set_theme') === 'true'; + if (!userHasExplicitlySetTheme) { + const prefersDarkMode = + window.matchMedia && + window.matchMedia('(prefers-color-scheme: dark)').matches; + + if (prefersDarkMode) { + dispatch(setTheme(Theme.dark, { isSystemPreference: true })); + } else { + dispatch(setTheme(Theme.light, { isSystemPreference: true })); + } + } + + // Listen for changes to system color scheme preference + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + + const handleChange = (e) => { + if (localStorage.getItem('has_set_theme') !== 'true') { + dispatch( + setTheme(e.matches ? Theme.dark : Theme.light, { + isSystemPreference: true + }) + ); + } + }; + + // Add event listener with modern API if available + if (mediaQuery.addEventListener) { + mediaQuery.addEventListener('change', handleChange); + } else { + // Fallback for older browsers + mediaQuery.addListener(handleChange); + } + + // Clean up event listener + return () => { + if (mediaQuery.removeEventListener) { + mediaQuery.removeEventListener('change', handleChange); + } else { + mediaQuery.removeListener(handleChange); + } + }; + }, [dispatch]); + return ( {children} ); diff --git a/client/modules/IDE/actions/preferences.js b/client/modules/IDE/actions/preferences.js index e0473bd995..defd242eed 100644 --- a/client/modules/IDE/actions/preferences.js +++ b/client/modules/IDE/actions/preferences.js @@ -177,7 +177,7 @@ export function setGridOutput(value) { }; } -export function setTheme(value) { +export function setTheme(value, { isSystemPreference = false } = {}) { // return { // type: ActionTypes.SET_THEME, // value @@ -187,6 +187,13 @@ export function setTheme(value) { type: ActionTypes.SET_THEME, value }); + + // If this is a user-initiated theme change (not from system preference), + // mark that the user has explicitly set a theme + if (!isSystemPreference) { + localStorage.setItem('has_set_theme', 'true'); + } + const state = getState(); if (state.user.authenticated) { const formParams = { diff --git a/client/modules/IDE/components/Preferences/Preferences.unit.test.jsx b/client/modules/IDE/components/Preferences/Preferences.unit.test.jsx index 55f9827d23..fdba967652 100644 --- a/client/modules/IDE/components/Preferences/Preferences.unit.test.jsx +++ b/client/modules/IDE/components/Preferences/Preferences.unit.test.jsx @@ -256,6 +256,21 @@ describe('', () => { }; describe('testing theme switching', () => { + beforeEach(() => { + // Mock localStorage for theme tests + Object.defineProperty(window, 'localStorage', { + value: { + getItem: jest.fn().mockImplementation((key) => { + if (key === 'has_set_theme') return 'true'; + return null; + }), + setItem: jest.fn(), + removeItem: jest.fn() + }, + writable: true + }); + }); + describe('dark mode', () => { it('switch to light', () => { subject({ theme: 'dark' }); diff --git a/client/modules/IDE/components/Preferences/index.jsx b/client/modules/IDE/components/Preferences/index.jsx index fa5859400e..bd09c8da9b 100644 --- a/client/modules/IDE/components/Preferences/index.jsx +++ b/client/modules/IDE/components/Preferences/index.jsx @@ -102,6 +102,29 @@ export default function Preferences() {

{t('Preferences.Theme')}

+ { + localStorage.removeItem('has_set_theme'); + const prefersDarkMode = + window.matchMedia && + window.matchMedia('(prefers-color-scheme: dark)').matches; + dispatch( + setTheme(prefersDarkMode ? 'dark' : 'light', { + isSystemPreference: true + }) + ); + }} + aria-label={t('Preferences.SystemThemeARIA')} + name="system theme" + id="system-theme-on" + className="preference__radio-button" + value="system" + checked={localStorage.getItem('has_set_theme') !== 'true'} + /> + dispatch(setTheme('light'))} @@ -110,7 +133,10 @@ export default function Preferences() { id="light-theme-on" className="preference__radio-button" value="light" - checked={theme === 'light'} + checked={ + theme === 'light' && + localStorage.getItem('has_set_theme') === 'true' + } />