settings: replace darkMode with useDarkMode hook

This is a minor breaking change for users that don't have dark mode
set to the default value since the localStorage key has changed (hard-
coded in 3rd party library).
This commit is contained in:
David Lechner
2022-03-14 16:39:17 -05:00
parent 062968ed9d
commit 9518a21241
11 changed files with 29 additions and 170 deletions
+2 -1
View File
@@ -6,5 +6,6 @@ import { testRender } from '../../test';
import App from './App';
it.each([false, true])('should render', (darkMode) => {
testRender(<App />, { settings: { darkMode } });
localStorage.setItem('usehooks-ts-dark-mode', String(darkMode));
testRender(<App />);
});
+5 -5
View File
@@ -5,7 +5,7 @@ import { Classes } from '@blueprintjs/core';
import React, { useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import SplitterLayout from 'react-splitter-layout';
import { useLocalStorage } from 'usehooks-ts';
import { useDarkMode, useLocalStorage } from 'usehooks-ts';
import Editor, { EditorType } from '../editor/Editor';
import Explorer from '../explorer/Explorer';
import { useSelector } from '../reducers';
@@ -130,7 +130,7 @@ type AppProps = {
};
const App: React.VoidFunctionComponent<AppProps> = ({ onEditorChanged }) => {
const darkMode = useSelector((s): boolean => s.settings.darkMode);
const { isDarkMode } = useDarkMode();
const showDocs = useSelector((s): boolean => s.settings.showDocs);
const [isDragging, setIsDragging] = useState(false);
const dispatch = useDispatch();
@@ -138,17 +138,17 @@ const App: React.VoidFunctionComponent<AppProps> = ({ onEditorChanged }) => {
const [docsSplit, setDocsSplit] = useLocalStorage('app-docs-split', 30);
const [terminalSplit, setTerminalSplit] = useLocalStorage('app-terminal-split', 30);
// darkMode class has to be applied to body element, otherwise it won't
// Classes.DARK has to be applied to body element, otherwise it won't
// affect portals
useEffect(() => {
if (!darkMode) {
if (!isDarkMode) {
// no class for light mode, so nothing to do
return;
}
document.body.classList.add(Classes.DARK);
return () => document.body.classList.remove(Classes.DARK);
}, [darkMode]);
}, [isDarkMode]);
return (
<div className="pb-app h-100 w-100 p-absolute">
+4 -4
View File
@@ -9,10 +9,10 @@ import xcodeTheme from 'monaco-themes/themes/Xcode_default.json';
import React, { useState } from 'react';
import MonacoEditor, { monaco } from 'react-monaco-editor';
import { useDispatch } from 'react-redux';
import { useDarkMode } from 'usehooks-ts';
import { IDisposable } from 'xterm';
import { fileStorageWriteFile } from '../fileStorage/actions';
import { compile } from '../mpy/actions';
import { useSelector } from '../reducers';
import { toggleBoolean } from '../settings/actions';
import { BooleanSettingId } from '../settings/defaults';
import { isMacOS } from '../utils/os';
@@ -136,13 +136,13 @@ const EditorContextMenu: React.VoidFunctionComponent<EditorContextMenuProps> = (
);
};
type EditorProps = { onEditorChanged: (editor: EditorType) => void };
type EditorProps = { onEditorChanged?: (editor: EditorType) => void };
const Editor: React.VoidFunctionComponent<EditorProps> = ({ onEditorChanged }) => {
const dispatch = useDispatch();
const [editor, setEditor] = useState<EditorType>(null);
const darkMode = useSelector((s) => s.settings.darkMode);
const { isDarkMode } = useDarkMode();
const [i18n] = useI18n({ id: 'editor', translations: { en }, fallback: en });
@@ -158,7 +158,7 @@ const Editor: React.VoidFunctionComponent<EditorProps> = ({ onEditorChanged }) =
>
<MonacoEditor
language={pybricksMicroPythonId}
theme={darkMode ? tomorrowNightEightiesId : xcodeId}
theme={isDarkMode ? tomorrowNightEightiesId : xcodeId}
width="100%"
height="100%"
options={{
+4 -10
View File
@@ -21,6 +21,7 @@ import { Tooltip2 } from '@blueprintjs/popover2';
import { useI18n } from '@shopify/react-i18n';
import React, { useMemo, useState } from 'react';
import { useDispatch } from 'react-redux';
import { useDarkMode } from 'usehooks-ts';
import AboutDialog from '../about/AboutDialog';
import { appCheckForUpdate, appReload, appShowInstallPrompt } from '../app/actions';
import {
@@ -50,9 +51,9 @@ const SettingsDrawer: React.VoidFunctionComponent<SettingsProps> = ({
onClose,
}) => {
const [isAboutDialogOpen, setIsAboutDialogOpen] = useState(false);
const { isDarkMode, toggle: toggleDarkMode } = useDarkMode();
const showDocs = useSelector((s) => s.settings.showDocs);
const darkMode = useSelector((s) => s.settings.darkMode);
const flashCurrentProgram = useSelector((s) => s.settings.flashCurrentProgram);
const isServiceWorkerRegistered = useSelector(
(s) => s.app.isServiceWorkerRegistered,
@@ -147,15 +148,8 @@ const SettingsDrawer: React.VoidFunctionComponent<SettingsProps> = ({
label={i18n.translate(
SettingsStringId.AppearanceDarkModeLabel,
)}
checked={darkMode}
onChange={(e) =>
dispatch(
setBoolean(
BooleanSettingId.DarkMode,
(e.target as HTMLInputElement).checked,
),
)
}
checked={isDarkMode}
onChange={toggleDarkMode}
/>
</Tooltip2>
</FormGroup>
+1 -6
View File
@@ -1,13 +1,10 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021 The Pybricks Authors
// Copyright (c) 2021-2022 The Pybricks Authors
// Definitions for user selectable settings.
import { prefersDarkMode } from '../utils/os';
export enum BooleanSettingId {
ShowDocs = 'showDocs',
DarkMode = 'darkMode',
FlashCurrentProgram = 'flashCurrentProgram',
}
@@ -15,8 +12,6 @@ export function getDefaultBooleanValue(id: BooleanSettingId): boolean {
switch (id) {
case BooleanSettingId.ShowDocs:
return window.innerWidth >= 1024;
case BooleanSettingId.DarkMode:
return prefersDarkMode();
case BooleanSettingId.FlashCurrentProgram:
return false;
// istanbul ignore next: it is a programmer error if we hit this
+3 -12
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021 The Pybricks Authors
import { AnyAction } from 'redux';
import { didBooleanChange, didStringChange } from './actions';
import { BooleanSettingId, StringSettingId } from './defaults';
@@ -8,7 +11,6 @@ type State = ReturnType<typeof reducers>;
test('initial state', () => {
expect(reducers(undefined, {} as AnyAction)).toMatchInlineSnapshot(`
Object {
"darkMode": false,
"flashCurrentProgram": false,
"hubName": "",
"isHubNameValid": true,
@@ -17,17 +19,6 @@ test('initial state', () => {
`);
});
describe('darkMode', () => {
test('setting changed', () => {
expect(
reducers(
{ darkMode: false } as State,
didBooleanChange(BooleanSettingId.DarkMode, true),
).darkMode,
).toBe(true);
});
});
describe('showDocs', () => {
test('setting changed', () => {
expect(
-15
View File
@@ -12,20 +12,6 @@ import {
const encoder = new TextEncoder();
const darkMode: Reducer<boolean> = (
state = getDefaultBooleanValue(BooleanSettingId.DarkMode),
action,
) => {
if (didBooleanChange.matches(action)) {
if (action.id === BooleanSettingId.DarkMode) {
return action.newState;
}
return state;
}
return state;
};
const showDocs: Reducer<boolean> = (
state = getDefaultBooleanValue(BooleanSettingId.ShowDocs),
action,
@@ -87,7 +73,6 @@ const isHubNameValid: Reducer<boolean> = (state = true, action) => {
};
export default combineReducers({
darkMode,
showDocs,
flashCurrentProgram,
hubName,
-84
View File
@@ -151,69 +151,6 @@ describe('startup', () => {
await saga.end();
});
});
describe('darkMode', () => {
test('with no value set', async () => {
const saga = new AsyncSaga(settings);
jest.spyOn(
Object.getPrototypeOf(window.localStorage),
'getItem',
).mockReturnValue(null);
saga.put(didStart());
// does nothing
await saga.end();
});
test('with value set to true', async () => {
const saga = new AsyncSaga(settings);
jest.spyOn(
Object.getPrototypeOf(window.localStorage),
'getItem',
).mockImplementation((key) => {
switch (key) {
case 'setting.darkMode':
return 'true';
default:
return null;
}
});
saga.put(didStart());
// requests to enable dark mode
const action = await saga.take();
expect(action).toEqual(didBooleanChange(BooleanSettingId.DarkMode, true));
await saga.end();
});
test('with value set to false', async () => {
const saga = new AsyncSaga(settings);
jest.spyOn(
Object.getPrototypeOf(window.localStorage),
'getItem',
).mockImplementation((key) => {
switch (key) {
case 'setting.darkMode':
return 'false';
default:
return null;
}
});
saga.put(didStart());
// does nothing
await saga.end();
});
});
});
describe('store settings to local storage', () => {
@@ -276,27 +213,6 @@ describe('store settings to local storage', () => {
await saga.end();
});
test('darkMode', async () => {
const saga = new AsyncSaga(settings);
saga.updateState({ settings: { darkMode: true } });
const mockSetItem = jest
.spyOn(Object.getPrototypeOf(window.localStorage), 'setItem')
.mockImplementation((key, value) => {
expect(key).toBe('setting.darkMode');
expect(value).toBe('false');
});
saga.put(setBoolean(BooleanSettingId.DarkMode, false));
expect(mockSetItem).toHaveBeenCalled();
const action = await saga.take();
expect(action).toEqual(didBooleanChange(BooleanSettingId.DarkMode, false));
await saga.end();
});
test('flashCurrentProgram', async () => {
const saga = new AsyncSaga(settings);
+9 -9
View File
@@ -1,14 +1,14 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2021 The Pybricks Authors
// Copyright (c) 2020-2022 The Pybricks Authors
import { Menu, MenuDivider, MenuItem, ResizeSensor } from '@blueprintjs/core';
import { ContextMenu2, ContextMenu2ContentProps } from '@blueprintjs/popover2';
import { useI18n } from '@shopify/react-i18n';
import React, { useContext, useEffect, useMemo, useRef } from 'react';
import { useDispatch } from 'react-redux';
import { useDarkMode } from 'usehooks-ts';
import { Terminal as XTerm } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import { useSelector } from '../reducers';
import { isMacOS } from '../utils/os';
import { TerminalContext } from './TerminalContext';
import { receiveData } from './actions';
@@ -100,7 +100,7 @@ function createContextMenu(
const Terminal: React.FC = (_props) => {
const { xterm, fitAddon } = useMemo(createXTerm, [createXTerm]);
const terminalRef = useRef<HTMLDivElement>(null);
const darkMode = useSelector((s) => s.settings.darkMode);
const { isDarkMode } = useDarkMode();
const dispatch = useDispatch();
const terminalStream = useContext(TerminalContext);
@@ -118,16 +118,16 @@ const Terminal: React.FC = (_props) => {
return () => xterm.dispose();
}, [xterm]);
// wire up darkMode to terminal
// wire up isDarkMode to terminal
useEffect(() => {
xterm.options.theme = {
background: darkMode ? 'black' : 'white',
foreground: darkMode ? 'white' : 'black',
cursor: darkMode ? 'white' : 'black',
background: isDarkMode ? 'black' : 'white',
foreground: isDarkMode ? 'white' : 'black',
cursor: isDarkMode ? 'white' : 'black',
// transparency is needed to work around https://github.com/xtermjs/xterm.js/issues/2808
selection: darkMode ? 'rgb(81,81,81,0.5)' : 'rgba(181,213,255,0.5)', // this should match AceEditor theme
selection: isDarkMode ? 'rgb(81,81,81,0.5)' : 'rgba(181,213,255,0.5)', // this should match AceEditor theme
};
}, [darkMode]);
}, [isDarkMode]);
const handleKeyDownEvent = (e: KeyboardEvent): void => {
// implement CTRL+SHIFT+C keyboard shortcut for copying text from terminal
+1 -16
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021 The Pybricks Authors
import { isAndroid, isMacOS, isWindows, prefersDarkMode } from './os';
import { isAndroid, isMacOS, isWindows } from './os';
afterEach(() => {
jest.resetAllMocks();
@@ -39,18 +39,3 @@ describe('isWindows', () => {
expect(isWindows()).toBeFalsy();
});
});
describe('prefersDarkMode', () => {
test('is true', () => {
jest.spyOn(window, 'matchMedia').mockReturnValue({
matches: true,
} as MediaQueryList);
expect(prefersDarkMode()).toBeTruthy();
});
test('is false', () => {
jest.spyOn(window, 'matchMedia').mockReturnValue({
matches: false,
} as MediaQueryList);
expect(prefersDarkMode()).toBeFalsy();
});
});
-8
View File
@@ -29,11 +29,3 @@ export function isMacOS(): boolean {
export function isWindows(): boolean {
return /win/i.test(navigator.platform);
}
/**
* Tests if the OS is set to dark mode.
* @returns: `true` if dark mode should be preferred, otherwise `false`.
*/
export function prefersDarkMode(): boolean {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}