Skip to content

Added accesibility for default system theme to p5.js-web-editor #3371

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 11 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions client/jest.setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
57 changes: 54 additions & 3 deletions client/modules/App/components/ThemeProvider.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<ThemeProvider theme={{ ...theme[currentTheme] }}>{children}</ThemeProvider>
);
Expand Down
9 changes: 8 additions & 1 deletion client/modules/IDE/actions/preferences.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ export function setGridOutput(value) {
};
}

export function setTheme(value) {
export function setTheme(value, { isSystemPreference = false } = {}) {
// return {
// type: ActionTypes.SET_THEME,
// value
Expand All @@ -194,6 +194,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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -256,9 +256,27 @@ describe('<Preferences />', () => {
};

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' });
const { store } = subject({ theme: 'dark' });

// Ensure the theme is actually set to dark in the Redux store
expect(store.getState().preferences.theme).toBe('dark');

const themeRadioCurrent = screen.getByRole('radio', {
name: /dark theme on/i
Expand Down
38 changes: 35 additions & 3 deletions client/modules/IDE/components/Preferences/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,29 @@ export default function Preferences() {
<div className="preference">
<h4 className="preference__title">{t('Preferences.Theme')}</h4>
<fieldset className="preference__options">
<input
type="radio"
onChange={() => {
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'}
/>
<label htmlFor="system-theme-on" className="preference__option">
{t('Preferences.SystemTheme')}
</label>
<input
type="radio"
onChange={() => dispatch(setTheme('light'))}
Expand All @@ -180,7 +203,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'
}
/>
<label htmlFor="light-theme-on" className="preference__option">
{t('Preferences.LightTheme')}
Expand All @@ -193,7 +219,10 @@ export default function Preferences() {
id="dark-theme-on"
className="preference__radio-button"
value="dark"
checked={theme === 'dark'}
checked={
theme === 'dark' &&
localStorage.getItem('has_set_theme') === 'true'
}
/>
<label htmlFor="dark-theme-on" className="preference__option">
{t('Preferences.DarkTheme')}
Expand All @@ -206,7 +235,10 @@ export default function Preferences() {
id="high-contrast-theme-on"
className="preference__radio-button"
value="contrast"
checked={theme === 'contrast'}
checked={
theme === 'contrast' &&
localStorage.getItem('has_set_theme') === 'true'
}
/>
<label
htmlFor="high-contrast-theme-on"
Expand Down
2 changes: 1 addition & 1 deletion client/modules/IDE/pages/FullView.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import Helmet from 'react-helmet';
import { Helmet } from 'react-helmet';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since Helmet is currently a default export in this file, could we preserve that or is there a specific reason to update it to be a named one?

Copy link
Contributor Author

@takshittt takshittt May 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The codebase uses named imports consistently, so introducing a default export would create inconsistency. While there's no technical limitation, named exports also align better with TypeScript, especially as the organization is migrating to it.
I'd love to hear your thoughts on this

import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
import PreviewFrame from '../components/PreviewFrame';
Expand Down
2 changes: 1 addition & 1 deletion client/modules/Legal/pages/Legal.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import axios from 'axios';
import PropTypes from 'prop-types';
import React, { useEffect, useState } from 'react';
import Helmet from 'react-helmet';
import { Helmet } from 'react-helmet';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same question as above!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same with this file.

import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import RouterTab from '../../../common/RouterTab';
Expand Down
Loading