Set default theme based on system preferences

This commit is contained in:
Alejandro Celaya 2023-12-18 23:05:17 +01:00
parent ddc466b797
commit 8907ea5310
9 changed files with 67 additions and 56 deletions

View file

@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
### Changed ### Changed
* [#338](https://github.com/shlinkio/shlink-web-client/issues/338) Extract `@shlinkio/shlink-web-component` and `@shlinkio/shlink-frontend-kit` as external libs. * [#338](https://github.com/shlinkio/shlink-web-client/issues/338) Extract `@shlinkio/shlink-web-component` and `@shlinkio/shlink-frontend-kit` as external libs.
* [#978](https://github.com/shlinkio/shlink-web-client/issues/978) Use system preferred theme as default theme.
* Use API client from `@shlinkio/shlink-js-sdk` to consume Shlink servers. * Use API client from `@shlinkio/shlink-js-sdk` to consume Shlink servers.
* [#902](https://github.com/shlinkio/shlink-web-client/pull/902) Docker image is no longer running as root. As a side effect, exposed port is `8080`, not `80` anymore. * [#902](https://github.com/shlinkio/shlink-web-client/pull/902) Docker image is no longer running as root. As a side effect, exposed port is `8080`, not `80` anymore.

View file

@ -21,3 +21,4 @@ afterEach(() => {
HTMLCanvasElement.prototype.getContext = (() => {}) as any; HTMLCanvasElement.prototype.getContext = (() => {}) as any;
(global as any).scrollTo = () => {}; (global as any).scrollTo = () => {};
(global as any).matchMedia = () => ({ matches: false });

14
package-lock.json generated
View file

@ -15,7 +15,7 @@
"@fortawesome/react-fontawesome": "^0.2.0", "@fortawesome/react-fontawesome": "^0.2.0",
"@json2csv/plainjs": "^7.0.3", "@json2csv/plainjs": "^7.0.3",
"@reduxjs/toolkit": "^2.0.1", "@reduxjs/toolkit": "^2.0.1",
"@shlinkio/data-manipulation": "^1.0.2", "@shlinkio/data-manipulation": "^1.0.3",
"@shlinkio/shlink-frontend-kit": "^0.4.1", "@shlinkio/shlink-frontend-kit": "^0.4.1",
"@shlinkio/shlink-js-sdk": "^0.2.0", "@shlinkio/shlink-js-sdk": "^0.2.0",
"@shlinkio/shlink-web-component": "^0.4.1", "@shlinkio/shlink-web-component": "^0.4.1",
@ -2934,9 +2934,9 @@
] ]
}, },
"node_modules/@shlinkio/data-manipulation": { "node_modules/@shlinkio/data-manipulation": {
"version": "1.0.2", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/@shlinkio/data-manipulation/-/data-manipulation-1.0.2.tgz", "resolved": "https://registry.npmjs.org/@shlinkio/data-manipulation/-/data-manipulation-1.0.3.tgz",
"integrity": "sha512-Nzlr9cnKlehqSkoAFR8W/HJF9Nl2OU+zhOluQbSGBhUQgOjOJi00uAflib8LADKX2KUQexqDiz+sCwl8Pf4VKA==" "integrity": "sha512-DPEU5IykmDrf7Z71miIi2xavSKkxAiqu8KuUsqEGmzGb31ENW0eZvMOfN+wNJbSLaRYqPsW465AwJqxTWUAGVA=="
}, },
"node_modules/@shlinkio/eslint-config-js-coding-standard": { "node_modules/@shlinkio/eslint-config-js-coding-standard": {
"version": "2.3.0", "version": "2.3.0",
@ -13049,9 +13049,9 @@
"optional": true "optional": true
}, },
"@shlinkio/data-manipulation": { "@shlinkio/data-manipulation": {
"version": "1.0.2", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/@shlinkio/data-manipulation/-/data-manipulation-1.0.2.tgz", "resolved": "https://registry.npmjs.org/@shlinkio/data-manipulation/-/data-manipulation-1.0.3.tgz",
"integrity": "sha512-Nzlr9cnKlehqSkoAFR8W/HJF9Nl2OU+zhOluQbSGBhUQgOjOJi00uAflib8LADKX2KUQexqDiz+sCwl8Pf4VKA==" "integrity": "sha512-DPEU5IykmDrf7Z71miIi2xavSKkxAiqu8KuUsqEGmzGb31ENW0eZvMOfN+wNJbSLaRYqPsW465AwJqxTWUAGVA=="
}, },
"@shlinkio/eslint-config-js-coding-standard": { "@shlinkio/eslint-config-js-coding-standard": {
"version": "2.3.0", "version": "2.3.0",

View file

@ -5,6 +5,7 @@
"homepage": "", "homepage": "",
"repository": "https://github.com/shlinkio/shlink-web-client", "repository": "https://github.com/shlinkio/shlink-web-client",
"license": "MIT", "license": "MIT",
"type": "module",
"scripts": { "scripts": {
"lint": "npm run lint:css && npm run lint:js", "lint": "npm run lint:css && npm run lint:js",
"lint:css": "stylelint src/*.scss src/**/*.scss", "lint:css": "stylelint src/*.scss src/**/*.scss",
@ -31,7 +32,7 @@
"@fortawesome/react-fontawesome": "^0.2.0", "@fortawesome/react-fontawesome": "^0.2.0",
"@json2csv/plainjs": "^7.0.3", "@json2csv/plainjs": "^7.0.3",
"@reduxjs/toolkit": "^2.0.1", "@reduxjs/toolkit": "^2.0.1",
"@shlinkio/data-manipulation": "^1.0.2", "@shlinkio/data-manipulation": "^1.0.3",
"@shlinkio/shlink-frontend-kit": "^0.4.1", "@shlinkio/shlink-frontend-kit": "^0.4.1",
"@shlinkio/shlink-js-sdk": "^0.2.0", "@shlinkio/shlink-js-sdk": "^0.2.0",
"@shlinkio/shlink-web-component": "^0.4.1", "@shlinkio/shlink-web-component": "^0.4.1",

View file

@ -1,4 +1,4 @@
import { changeThemeInMarkup } from '@shlinkio/shlink-frontend-kit'; import { changeThemeInMarkup, getSystemPreferredTheme } from '@shlinkio/shlink-frontend-kit';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import type { FC } from 'react'; import type { FC } from 'react';
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
@ -58,7 +58,7 @@ const App: FCWithDeps<AppProps, AppDeps> = (
}, [fetchServers]); }, [fetchServers]);
useEffect(() => { useEffect(() => {
changeThemeInMarkup(settings.ui?.theme ?? 'light'); changeThemeInMarkup(settings.ui?.theme ?? getSystemPreferredTheme());
}, [settings.ui?.theme]); }, [settings.ui?.theme]);
return ( return (

View file

@ -1,27 +1,34 @@
import { faMoon, faSun } from '@fortawesome/free-solid-svg-icons'; import { faMoon, faSun } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { Theme } from '@shlinkio/shlink-frontend-kit'; import type { Theme } from '@shlinkio/shlink-frontend-kit';
import { SimpleCard, ToggleSwitch } from '@shlinkio/shlink-frontend-kit'; import { getSystemPreferredTheme, SimpleCard, ToggleSwitch } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react'; import type { FC } from 'react';
import { useMemo } from 'react';
import type { AppSettings, UiSettings } from './reducers/settings'; import type { AppSettings, UiSettings } from './reducers/settings';
import './UserInterfaceSettings.scss'; import './UserInterfaceSettings.scss';
interface UserInterfaceProps { interface UserInterfaceProps {
settings: AppSettings; settings: AppSettings;
setUiSettings: (settings: UiSettings) => void; setUiSettings: (settings: UiSettings) => void;
/* Test seam */
_matchMedia?: typeof window.matchMedia;
} }
export const UserInterfaceSettings: FC<UserInterfaceProps> = ({ settings: { ui }, setUiSettings }) => ( export const UserInterfaceSettings: FC<UserInterfaceProps> = ({ settings: { ui }, setUiSettings, _matchMedia }) => {
<SimpleCard title="User interface" className="h-100"> const currentTheme = useMemo(() => ui?.theme ?? getSystemPreferredTheme(_matchMedia), [ui?.theme, _matchMedia]);
<FontAwesomeIcon icon={ui?.theme === 'dark' ? faMoon : faSun} className="user-interface__theme-icon" /> return (
<ToggleSwitch <SimpleCard title="User interface" className="h-100">
checked={ui?.theme === 'dark'} <FontAwesomeIcon icon={currentTheme === 'dark' ? faMoon : faSun} className="user-interface__theme-icon" />
onChange={(useDarkTheme) => { <ToggleSwitch
const theme: Theme = useDarkTheme ? 'dark' : 'light'; checked={currentTheme === 'dark'}
setUiSettings({ ...ui, theme }); onChange={(useDarkTheme) => {
}} const theme: Theme = useDarkTheme ? 'dark' : 'light';
> setUiSettings({ ...ui, theme });
Use dark theme. }}
</ToggleSwitch> >
</SimpleCard> Use dark theme.
); </ToggleSwitch>
</SimpleCard>
);
};

View file

@ -2,6 +2,7 @@ import type { PayloadAction, PrepareAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit';
import { mergeDeepRight } from '@shlinkio/data-manipulation'; import { mergeDeepRight } from '@shlinkio/data-manipulation';
import type { Theme } from '@shlinkio/shlink-frontend-kit'; import type { Theme } from '@shlinkio/shlink-frontend-kit';
import { getSystemPreferredTheme } from '@shlinkio/shlink-frontend-kit';
import type { import type {
Settings, Settings,
ShortUrlCreationSettings, ShortUrlCreationSettings,
@ -34,7 +35,7 @@ const initialState: AppSettings = {
validateUrls: false, validateUrls: false,
}, },
ui: { ui: {
theme: 'light', theme: getSystemPreferredTheme(),
}, },
visits: { visits: {
defaultInterval: 'last30Days', defaultInterval: 'last30Days',

View file

@ -1,7 +1,6 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn'; import { fromPartial } from '@total-typescript/shoehorn';
import { createMemoryHistory } from 'history'; import { MemoryRouter } from 'react-router-dom';
import { Router } from 'react-router-dom';
import { AppFactory } from '../../src/app/App'; import { AppFactory } from '../../src/app/App';
import { checkAccessibility } from '../__helpers__/accessibility'; import { checkAccessibility } from '../__helpers__/accessibility';
@ -18,22 +17,17 @@ describe('<App />', () => {
ShlinkVersionsContainer: () => <>ShlinkVersions</>, ShlinkVersionsContainer: () => <>ShlinkVersions</>,
}), }),
); );
const setUp = (activeRoute = '/') => { const setUp = (activeRoute = '/') => render(
const history = createMemoryHistory(); <MemoryRouter initialEntries={[{ pathname: activeRoute }]}>
history.push(activeRoute); <App
fetchServers={() => {}}
return render( servers={{}}
<Router location={history.location} navigator={history}> settings={fromPartial({})}
<App appUpdated
fetchServers={() => {}} resetAppUpdate={() => {}}
servers={{}} />
settings={fromPartial({})} </MemoryRouter>,
appUpdated );
resetAppUpdate={() => {}}
/>
</Router>,
);
};
it('passes a11y checks', () => checkAccessibility(setUp())); it('passes a11y checks', () => checkAccessibility(setUp()));

View file

@ -1,4 +1,3 @@
import type { Theme } from '@shlinkio/shlink-frontend-kit';
import { screen } from '@testing-library/react'; import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn'; import { fromPartial } from '@total-typescript/shoehorn';
import type { UiSettings } from '../../src/settings/reducers/settings'; import type { UiSettings } from '../../src/settings/reducers/settings';
@ -8,18 +7,25 @@ import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<UserInterfaceSettings />', () => { describe('<UserInterfaceSettings />', () => {
const setUiSettings = vi.fn(); const setUiSettings = vi.fn();
const setUp = (ui?: UiSettings) => renderWithEvents( const setUp = (ui?: UiSettings, defaultDarkTheme = false) => renderWithEvents(
<UserInterfaceSettings settings={fromPartial({ ui })} setUiSettings={setUiSettings} />, <UserInterfaceSettings
settings={fromPartial({ ui })}
setUiSettings={setUiSettings}
_matchMedia={vi.fn().mockReturnValue({ matches: defaultDarkTheme })}
/>,
); );
it('passes a11y checks', () => checkAccessibility(setUp())); it('passes a11y checks', () => checkAccessibility(setUp()));
it.each([ it.each([
[{ theme: 'dark' as Theme }, true], [{ theme: 'dark' as const }, true, true],
[{ theme: 'light' as Theme }, false], [{ theme: 'dark' as const }, false, true],
[undefined, false], [{ theme: 'light' as const }, true, false],
])('toggles switch if theme is dark', (ui, expectedChecked) => { [{ theme: 'light' as const }, false, false],
setUp(ui); [undefined, false, false],
[undefined, true, true],
])('toggles switch if theme is dark', (ui, defaultDarkTheme, expectedChecked) => {
setUp(ui, defaultDarkTheme);
if (expectedChecked) { if (expectedChecked) {
expect(screen.getByLabelText('Use dark theme.')).toBeChecked(); expect(screen.getByLabelText('Use dark theme.')).toBeChecked();
@ -29,8 +35,8 @@ describe('<UserInterfaceSettings />', () => {
}); });
it.each([ it.each([
[{ theme: 'dark' as Theme }], [{ theme: 'dark' as const }],
[{ theme: 'light' as Theme }], [{ theme: 'light' as const }],
[undefined], [undefined],
])('shows different icons based on theme', (ui) => { ])('shows different icons based on theme', (ui) => {
setUp(ui); setUp(ui);
@ -38,8 +44,8 @@ describe('<UserInterfaceSettings />', () => {
}); });
it.each([ it.each([
['light' as Theme, 'dark' as Theme], ['light' as const, 'dark' as const],
['dark' as Theme, 'light' as Theme], ['dark' as const, 'light' as const],
])('invokes setUiSettings when theme toggle value changes', async (initialTheme, expectedTheme) => { ])('invokes setUiSettings when theme toggle value changes', async (initialTheme, expectedTheme) => {
const { user } = setUp({ theme: initialTheme }); const { user } = setUp({ theme: initialTheme });