Skip to content

Commit d893453

Browse files
authored
feat: Add 'system' theme (#38264)
1 parent 4c65ab3 commit d893453

File tree

13 files changed

+140
-89
lines changed

13 files changed

+140
-89
lines changed

packages/html-reporter/src/headerView.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@
3838
line-height: 1.25;
3939
}
4040

41+
.header-setting-theme {
42+
display: grid;
43+
margin-left: 22px
44+
}
45+
4146
@media only screen and (max-width: 600px) {
4247
.header-view {
4348
padding: 0;

packages/html-reporter/src/headerView.tsx

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { statusIcon } from './statusIcon';
2525
import { filterWithQuery } from './filter';
2626
import { linkifyText } from '@web/renderUtils';
2727
import { Dialog } from '@web/shared/dialog';
28-
import { useDarkModeSetting } from '@web/theme';
28+
import { kThemeOptions, type Theme, useThemeSetting } from '@web/theme';
2929
import { useSetting } from '@web/uiUtils';
3030

3131
export const HeaderView: React.FC<{
@@ -132,7 +132,7 @@ const NavLink: React.FC<{
132132
const SettingsButton: React.FC = () => {
133133
const settingsRef = React.useRef<HTMLDivElement>(null);
134134
const [settingsOpen, setSettingsOpen] = React.useState(false);
135-
const [darkMode, setDarkMode] = useDarkModeSetting();
135+
const [theme, setTheme] = useThemeSetting();
136136
const [mergeFiles, setMergeFiles] = useSetting('mergeFiles', false);
137137

138138
return <>
@@ -148,33 +148,34 @@ const SettingsButton: React.FC = () => {
148148
}}
149149
onMouseDown={preventDefault}>
150150
{icons.settings()}
151-
<Dialog
152-
open={settingsOpen}
153-
minWidth={150}
154-
verticalOffset={4}
155-
requestClose={() => setSettingsOpen(false)}
156-
anchor={settingsRef}
157-
dataTestId='settings-dialog'
158-
>
159-
<label style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4 }} onClick={stopPropagation}>
160-
<input type='checkbox' checked={darkMode} onChange={() => setDarkMode(!darkMode)}></input>
161-
Dark mode
162-
</label>
163-
<label style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4 }} onClick={stopPropagation}>
164-
<input type='checkbox' checked={mergeFiles} onChange={() => setMergeFiles(!mergeFiles)}></input>
165-
Merge files
166-
</label>
167-
</Dialog>
168151
</div>
152+
153+
<Dialog
154+
open={settingsOpen}
155+
minWidth={150}
156+
verticalOffset={4}
157+
requestClose={() => setSettingsOpen(false)}
158+
anchor={settingsRef}
159+
dataTestId='settings-dialog'
160+
>
161+
<label className='header-setting-theme'>
162+
Theme:
163+
<select value={theme} onChange={e => setTheme(e.target.value as Theme)}>
164+
{kThemeOptions.map(option => (
165+
<option key={option.value} value={option.value}>{option.label}</option>
166+
))}
167+
</select>
168+
</label>
169+
170+
<label style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4 }}>
171+
<input type='checkbox' checked={mergeFiles} onChange={() => setMergeFiles(!mergeFiles)}></input>
172+
Merge files
173+
</label>
174+
</Dialog>
169175
</>;
170176
};
171177

172178
const preventDefault = (e: any) => {
173179
e.stopPropagation();
174180
e.preventDefault();
175181
};
176-
177-
const stopPropagation = (e: any) => {
178-
e.stopPropagation();
179-
e.stopImmediatePropagation();
180-
};

packages/recorder/src/recorder.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@
6767
align-items: center;
6868
}
6969

70+
.setting-theme {
71+
display: grid;
72+
margin-left: 22px
73+
}
74+
7075
.setting label {
7176
text-overflow: ellipsis;
7277
white-space: nowrap;

packages/recorder/src/recorder.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import * as React from 'react';
2626
import { CallLogView } from './callLog';
2727
import './recorder.css';
2828
import { asLocator } from '@isomorphic/locatorGenerators';
29-
import { useDarkModeSetting } from '@web/theme';
29+
import { kThemeOptions, type Theme, useThemeSetting } from '@web/theme';
3030
import { copy, useSetting } from '@web/uiUtils';
3131
import yaml from 'yaml';
3232
import { parseAriaSnapshot } from '@isomorphic/ariaSnapshot';
@@ -50,7 +50,7 @@ export const Recorder: React.FC<RecorderProps> = ({
5050
const [ariaSnapshot, setAriaSnapshot] = React.useState<string | undefined>();
5151
const [ariaSnapshotErrors, setAriaSnapshotErrors] = React.useState<SourceHighlight[]>();
5252
const [settingsOpen, setSettingsOpen] = React.useState(false);
53-
const [darkMode, setDarkMode] = useDarkModeSetting();
53+
const [theme, setTheme] = useThemeSetting();
5454
const [autoExpect, setAutoExpect] = useSetting<boolean>('autoExpect', false);
5555
const settingsButtonRef = React.useRef<HTMLButtonElement>(null);
5656
window.playwrightSelectSource = selectedSourceId => setSelectedFileId(selectedSourceId);
@@ -203,9 +203,13 @@ export const Recorder: React.FC<RecorderProps> = ({
203203
anchor={settingsButtonRef}
204204
dataTestId='settings-dialog'
205205
>
206-
<div key='dark-mode-setting' className='setting'>
207-
<input type='checkbox' id='dark-mode-setting' checked={darkMode} onChange={() => setDarkMode(!darkMode)} />
208-
<label htmlFor='dark-mode-setting'>Dark mode</label>
206+
<div key='dark-mode-setting' className='setting setting-theme'>
207+
<label htmlFor='dark-mode-setting'>Theme:</label>
208+
<select id='dark-mode-setting' value={theme} onChange={e => setTheme(e.target.value as Theme)}>
209+
{kThemeOptions.map(option => (
210+
<option key={option.value} value={option.value}>{option.label}</option>
211+
))}
212+
</select>
209213
</div>
210214
<div key='auto-expect-setting' className='setting' title='Automatically generate assertions while recording'>
211215
<input type='checkbox' id='auto-expect-setting' checked={autoExpect} onChange={() => {

packages/trace-viewer/src/ui/defaultSettingsView.tsx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
import * as React from 'react';
1818
import { type Setting, SettingsView } from './settingsView';
19-
import { useDarkModeSetting } from '@web/theme';
19+
import { kThemeOptions, type Theme, useThemeSetting } from '@web/theme';
2020
import { useSetting } from '@web/uiUtils';
2121

2222
/**
@@ -29,18 +29,19 @@ export const DefaultSettingsView: React.FC<{
2929
shouldPopulateCanvasFromScreenshot,
3030
setShouldPopulateCanvasFromScreenshot,
3131
] = useSetting('shouldPopulateCanvasFromScreenshot', false);
32-
const [darkMode, setDarkMode] = useDarkModeSetting();
32+
const [theme, setTheme] = useThemeSetting();
3333
const [mergeFiles, setMergeFiles] = useSetting('mergeFiles', false);
3434

3535
return (
3636
<SettingsView
3737
settings={[
3838
{
39-
type: 'check',
40-
value: darkMode,
41-
set: setDarkMode,
42-
name: 'Dark mode'
43-
},
39+
type: 'select',
40+
value: theme,
41+
set: setTheme,
42+
name: 'Theme',
43+
options: kThemeOptions
44+
} satisfies Setting<Theme>,
4445
...(location === 'ui-mode' ? [{
4546
type: 'check',
4647
value: mergeFiles,

packages/trace-viewer/src/ui/settingsView.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
grid-template-rows: auto auto;
4646
row-gap: 8px;
4747
margin: 0 16px 0 22px;
48+
line-height: initial;
4849
}
4950

5051
.settings-view .setting-select:not(:first-child) {

packages/trace-viewer/src/ui/settingsView.tsx

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import * as React from 'react';
1818
import './settingsView.css';
1919

20-
export type Setting = {
20+
export type Setting<Value extends string = string> = {
2121
name: string;
2222
title?: string;
2323
count?: number;
@@ -27,14 +27,14 @@ export type Setting = {
2727
set: (value: boolean) => void;
2828
} | {
2929
type: 'select',
30-
options: Array<{ label: string, value: string }>;
31-
value: string;
32-
set: (value: string) => void;
30+
options: Array<{ label: string, value: Value }>;
31+
value: Value;
32+
set: (value: Value) => void;
3333
});
3434

35-
export const SettingsView: React.FunctionComponent<{
36-
settings: Setting[];
37-
}> = ({ settings }) => {
35+
export const SettingsView = <Value extends string>(
36+
{ settings }: { settings: Setting<Value>[] }
37+
) => {
3838
return (
3939
<div className='vbox settings-view'>
4040
{settings.map(setting => {
@@ -50,7 +50,7 @@ export const SettingsView: React.FunctionComponent<{
5050
);
5151
};
5252

53-
const renderSetting = (setting: Setting, labelId: string) => {
53+
const renderSetting = <Value extends string>(setting: Setting<Value>, labelId: string) => {
5454
switch (setting.type) {
5555
case 'check':
5656
return (
@@ -68,7 +68,7 @@ const renderSetting = (setting: Setting, labelId: string) => {
6868
return (
6969
<>
7070
<label htmlFor={labelId}>{setting.name}:{!!setting.count && <span className='setting-counter'>{setting.count}</span>}</label>
71-
<select id={labelId} value={setting.value} onChange={e => setting.set(e.target.value)}>
71+
<select id={labelId} value={setting.value} onChange={e => setting.set(e.target.value as Value)}>
7272
{setting.options.map(option => (
7373
<option key={option.value} value={option.value}>
7474
{option.label}

packages/web/src/components/xtermWrapper.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import './xtermWrapper.css';
1919
import type { ITheme, Terminal } from '@xterm/xterm';
2020
import type { FitAddon } from '@xterm/addon-fit';
2121
import type { XtermModule } from './xtermModule';
22-
import { currentTheme, addThemeListener, removeThemeListener } from '../theme';
22+
import { currentDocumentTheme, addThemeListener, removeThemeListener } from '../theme';
2323
import { useMeasure } from '../uiUtils';
2424

2525
export type XtermDataSource = {
@@ -33,7 +33,7 @@ export const XtermWrapper: React.FC<{ source: XtermDataSource }> = ({
3333
source,
3434
}) => {
3535
const [measure, xtermElement] = useMeasure<HTMLDivElement>();
36-
const [theme, setTheme] = React.useState(currentTheme());
36+
const [theme, setTheme] = React.useState(currentDocumentTheme());
3737
const [modulePromise] = React.useState<Promise<XtermModule>>(import('./xtermModule').then(m => m.default));
3838
const terminal = React.useRef<{ terminal: Terminal, fitAddon: FitAddon } | null>(null);
3939

packages/web/src/theme.ts

Lines changed: 50 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,19 @@ declare global {
2424
}
2525
}
2626

27+
type DocumentTheme = 'dark-mode' | 'light-mode';
28+
export type Theme = DocumentTheme | 'system';
29+
30+
const kDefaultTheme: Theme = 'system';
31+
const kThemeSettingsKey = 'theme';
32+
export const kThemeOptions: { label: string; value: Theme }[] = [
33+
{ label: 'Dark mode', value: 'dark-mode' },
34+
{ label: 'Light mode', value: 'light-mode' },
35+
{ label: 'System', value: 'system' },
36+
] as const satisfies { label: string; value: Theme }[];
37+
38+
const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)');
39+
2740
export function applyTheme() {
2841
if (document.playwrightThemeInitialized)
2942
return;
@@ -36,49 +49,57 @@ export function applyTheme() {
3649
document.body.classList.add('inactive');
3750
}, false);
3851

39-
const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)');
40-
const defaultTheme = prefersDarkScheme.matches ? 'dark-mode' : 'light-mode';
52+
updateDocumentTheme(currentTheme());
4153

42-
const currentTheme = settings.getString('theme', defaultTheme);
43-
if (currentTheme === 'dark-mode')
44-
document.documentElement.classList.add('dark-mode');
45-
else
46-
document.documentElement.classList.add('light-mode');
54+
prefersDarkScheme.addEventListener('change', () => {
55+
updateDocumentTheme(currentTheme());
56+
});
4757
}
4858

49-
type Theme = 'dark-mode' | 'light-mode';
59+
const listeners = new Set<(theme: DocumentTheme) => void>();
60+
function updateDocumentTheme(newTheme: Theme) {
61+
const oldDocumentTheme = currentDocumentTheme();
62+
const newDocumentTheme = newTheme === 'system'
63+
? (prefersDarkScheme.matches ? 'dark-mode' : 'light-mode')
64+
: newTheme;
5065

51-
const listeners = new Set<(theme: Theme) => void>();
52-
export function toggleTheme() {
53-
const oldTheme = currentTheme();
54-
const newTheme = oldTheme === 'dark-mode' ? 'light-mode' : 'dark-mode';
66+
if (oldDocumentTheme === newDocumentTheme)
67+
return;
5568

56-
if (oldTheme)
57-
document.documentElement.classList.remove(oldTheme);
58-
document.documentElement.classList.add(newTheme);
59-
settings.setString('theme', newTheme);
69+
if (oldDocumentTheme)
70+
document.documentElement.classList.remove(oldDocumentTheme);
71+
document.documentElement.classList.add(newDocumentTheme);
6072
for (const listener of listeners)
61-
listener(newTheme);
73+
listener(newDocumentTheme);
6274
}
6375

64-
export function addThemeListener(listener: (theme: 'light-mode' | 'dark-mode') => void) {
76+
export function addThemeListener(listener: (theme: DocumentTheme) => void) {
6577
listeners.add(listener);
6678
}
6779

68-
export function removeThemeListener(listener: (theme: Theme) => void) {
80+
export function removeThemeListener(listener: (theme: DocumentTheme) => void) {
6981
listeners.delete(listener);
7082
}
7183

72-
export function currentTheme(): Theme {
73-
return document.documentElement.classList.contains('dark-mode') ? 'dark-mode' : 'light-mode';
84+
function currentTheme(): Theme {
85+
return settings.getString(kThemeSettingsKey, kDefaultTheme);
7486
}
7587

76-
export function useDarkModeSetting(): [boolean, (value: boolean) => void] {
77-
const [theme, setTheme] = React.useState(currentTheme() === 'dark-mode');
78-
return [theme, (value: boolean) => {
79-
const current = currentTheme() === 'dark-mode';
80-
if (current !== value)
81-
toggleTheme();
82-
setTheme(value);
83-
}];
88+
export function currentDocumentTheme(): DocumentTheme | null {
89+
if (document.documentElement.classList.contains('dark-mode'))
90+
return 'dark-mode';
91+
if (document.documentElement.classList.contains('light-mode'))
92+
return 'light-mode';
93+
return null;
94+
}
95+
96+
export function useThemeSetting(): [Theme, (value: Theme) => void] {
97+
const [theme, setTheme] = React.useState<Theme>(currentTheme());
98+
99+
React.useEffect(() => {
100+
settings.setString(kThemeSettingsKey, theme);
101+
updateDocumentTheme(theme);
102+
}, [theme]);
103+
104+
return [theme, setTheme];
84105
}

packages/web/src/uiUtils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,11 +209,11 @@ declare global {
209209
export class Settings {
210210
onChangeEmitter = new EventTarget();
211211

212-
getString(name: string, defaultValue: string): string {
212+
getString<T extends string>(name: string, defaultValue: T): T {
213213
return localStorage[name] || defaultValue;
214214
}
215215

216-
setString(name: string, value: string) {
216+
setString<T extends string>(name: string, value: T) {
217217
localStorage[name] = value;
218218
this.onChangeEmitter.dispatchEvent(new Event(name));
219219
window.saveSettings?.();

0 commit comments

Comments
 (0)