Skip to content
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
5 changes: 5 additions & 0 deletions packages/html-reporter/src/headerView.css
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@
line-height: 1.25;
}

.header-setting-theme {
display: grid;
margin-left: 22px
}

@media only screen and (max-width: 600px) {
.header-view {
padding: 0;
Expand Down
49 changes: 25 additions & 24 deletions packages/html-reporter/src/headerView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { statusIcon } from './statusIcon';
import { filterWithQuery } from './filter';
import { linkifyText } from '@web/renderUtils';
import { Dialog } from '@web/shared/dialog';
import { useDarkModeSetting } from '@web/theme';
import { kThemeOptions, type Theme, useThemeSetting } from '@web/theme';
import { useSetting } from '@web/uiUtils';

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

return <>
Expand All @@ -148,33 +148,34 @@ const SettingsButton: React.FC = () => {
}}
onMouseDown={preventDefault}>
{icons.settings()}
<Dialog
open={settingsOpen}
minWidth={150}
verticalOffset={4}
requestClose={() => setSettingsOpen(false)}
anchor={settingsRef}
dataTestId='settings-dialog'
>
<label style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4 }} onClick={stopPropagation}>
<input type='checkbox' checked={darkMode} onChange={() => setDarkMode(!darkMode)}></input>
Dark mode
</label>
<label style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4 }} onClick={stopPropagation}>
<input type='checkbox' checked={mergeFiles} onChange={() => setMergeFiles(!mergeFiles)}></input>
Merge files
</label>
</Dialog>
</div>

<Dialog
open={settingsOpen}
minWidth={150}
verticalOffset={4}
requestClose={() => setSettingsOpen(false)}
anchor={settingsRef}
dataTestId='settings-dialog'
>
<label className='header-setting-theme'>
Theme:
<select value={theme} onChange={e => setTheme(e.target.value as Theme)}>
{kThemeOptions.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>

<label style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4 }}>
<input type='checkbox' checked={mergeFiles} onChange={() => setMergeFiles(!mergeFiles)}></input>
Merge files
</label>
</Dialog>
</>;
};

const preventDefault = (e: any) => {
e.stopPropagation();
e.preventDefault();
};

const stopPropagation = (e: any) => {
e.stopPropagation();
e.stopImmediatePropagation();
};
5 changes: 5 additions & 0 deletions packages/recorder/src/recorder.css
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@
align-items: center;
}

.setting-theme {
display: grid;
margin-left: 22px
}

.setting label {
text-overflow: ellipsis;
white-space: nowrap;
Expand Down
14 changes: 9 additions & 5 deletions packages/recorder/src/recorder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import * as React from 'react';
import { CallLogView } from './callLog';
import './recorder.css';
import { asLocator } from '@isomorphic/locatorGenerators';
import { useDarkModeSetting } from '@web/theme';
import { kThemeOptions, type Theme, useThemeSetting } from '@web/theme';
import { copy, useSetting } from '@web/uiUtils';
import yaml from 'yaml';
import { parseAriaSnapshot } from '@isomorphic/ariaSnapshot';
Expand All @@ -50,7 +50,7 @@ export const Recorder: React.FC<RecorderProps> = ({
const [ariaSnapshot, setAriaSnapshot] = React.useState<string | undefined>();
const [ariaSnapshotErrors, setAriaSnapshotErrors] = React.useState<SourceHighlight[]>();
const [settingsOpen, setSettingsOpen] = React.useState(false);
const [darkMode, setDarkMode] = useDarkModeSetting();
const [theme, setTheme] = useThemeSetting();
const [autoExpect, setAutoExpect] = useSetting<boolean>('autoExpect', false);
const settingsButtonRef = React.useRef<HTMLButtonElement>(null);
window.playwrightSelectSource = selectedSourceId => setSelectedFileId(selectedSourceId);
Expand Down Expand Up @@ -203,9 +203,13 @@ export const Recorder: React.FC<RecorderProps> = ({
anchor={settingsButtonRef}
dataTestId='settings-dialog'
>
<div key='dark-mode-setting' className='setting'>
<input type='checkbox' id='dark-mode-setting' checked={darkMode} onChange={() => setDarkMode(!darkMode)} />
<label htmlFor='dark-mode-setting'>Dark mode</label>
<div key='dark-mode-setting' className='setting setting-theme'>
<label htmlFor='dark-mode-setting'>Theme:</label>
<select id='dark-mode-setting' value={theme} onChange={e => setTheme(e.target.value as Theme)}>
{kThemeOptions.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div key='auto-expect-setting' className='setting' title='Automatically generate assertions while recording'>
<input type='checkbox' id='auto-expect-setting' checked={autoExpect} onChange={() => {
Expand Down
15 changes: 8 additions & 7 deletions packages/trace-viewer/src/ui/defaultSettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import * as React from 'react';
import { type Setting, SettingsView } from './settingsView';
import { useDarkModeSetting } from '@web/theme';
import { kThemeOptions, type Theme, useThemeSetting } from '@web/theme';
import { useSetting } from '@web/uiUtils';

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

return (
<SettingsView
settings={[
{
type: 'check',
value: darkMode,
set: setDarkMode,
name: 'Dark mode'
},
type: 'select',
value: theme,
set: setTheme,
name: 'Theme',
options: kThemeOptions
} satisfies Setting<Theme>,
...(location === 'ui-mode' ? [{
type: 'check',
value: mergeFiles,
Expand Down
1 change: 1 addition & 0 deletions packages/trace-viewer/src/ui/settingsView.css
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
grid-template-rows: auto auto;
row-gap: 8px;
margin: 0 16px 0 22px;
line-height: initial;
}

.settings-view .setting-select:not(:first-child) {
Expand Down
18 changes: 9 additions & 9 deletions packages/trace-viewer/src/ui/settingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import * as React from 'react';
import './settingsView.css';

export type Setting = {
export type Setting<Value extends string = string> = {
name: string;
title?: string;
count?: number;
Expand All @@ -27,14 +27,14 @@ export type Setting = {
set: (value: boolean) => void;
} | {
type: 'select',
options: Array<{ label: string, value: string }>;
value: string;
set: (value: string) => void;
options: Array<{ label: string, value: Value }>;
value: Value;
set: (value: Value) => void;
});

export const SettingsView: React.FunctionComponent<{
settings: Setting[];
}> = ({ settings }) => {
export const SettingsView = <Value extends string>(
{ settings }: { settings: Setting<Value>[] }
) => {
return (
<div className='vbox settings-view'>
{settings.map(setting => {
Expand All @@ -50,7 +50,7 @@ export const SettingsView: React.FunctionComponent<{
);
};

const renderSetting = (setting: Setting, labelId: string) => {
const renderSetting = <Value extends string>(setting: Setting<Value>, labelId: string) => {
switch (setting.type) {
case 'check':
return (
Expand All @@ -68,7 +68,7 @@ const renderSetting = (setting: Setting, labelId: string) => {
return (
<>
<label htmlFor={labelId}>{setting.name}:{!!setting.count && <span className='setting-counter'>{setting.count}</span>}</label>
<select id={labelId} value={setting.value} onChange={e => setting.set(e.target.value)}>
<select id={labelId} value={setting.value} onChange={e => setting.set(e.target.value as Value)}>
{setting.options.map(option => (
<option key={option.value} value={option.value}>
{option.label}
Expand Down
4 changes: 2 additions & 2 deletions packages/web/src/components/xtermWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import './xtermWrapper.css';
import type { ITheme, Terminal } from '@xterm/xterm';
import type { FitAddon } from '@xterm/addon-fit';
import type { XtermModule } from './xtermModule';
import { currentTheme, addThemeListener, removeThemeListener } from '../theme';
import { currentDocumentTheme, addThemeListener, removeThemeListener } from '../theme';
import { useMeasure } from '../uiUtils';

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

Expand Down
79 changes: 50 additions & 29 deletions packages/web/src/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@ declare global {
}
}

type DocumentTheme = 'dark-mode' | 'light-mode';
export type Theme = DocumentTheme | 'system';

const kDefaultTheme: Theme = 'system';
const kThemeSettingsKey = 'theme';
export const kThemeOptions: { label: string; value: Theme }[] = [
{ label: 'Dark mode', value: 'dark-mode' },
{ label: 'Light mode', value: 'light-mode' },
{ label: 'System', value: 'system' },
] as const satisfies { label: string; value: Theme }[];

const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)');

export function applyTheme() {
if (document.playwrightThemeInitialized)
return;
Expand All @@ -36,49 +49,57 @@ export function applyTheme() {
document.body.classList.add('inactive');
}, false);

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

const currentTheme = settings.getString('theme', defaultTheme);
if (currentTheme === 'dark-mode')
document.documentElement.classList.add('dark-mode');
else
document.documentElement.classList.add('light-mode');
prefersDarkScheme.addEventListener('change', () => {
updateDocumentTheme(currentTheme());
});
}

type Theme = 'dark-mode' | 'light-mode';
const listeners = new Set<(theme: DocumentTheme) => void>();
function updateDocumentTheme(newTheme: Theme) {
const oldDocumentTheme = currentDocumentTheme();
const newDocumentTheme = newTheme === 'system'
? (prefersDarkScheme.matches ? 'dark-mode' : 'light-mode')
: newTheme;

const listeners = new Set<(theme: Theme) => void>();
export function toggleTheme() {
const oldTheme = currentTheme();
const newTheme = oldTheme === 'dark-mode' ? 'light-mode' : 'dark-mode';
if (oldDocumentTheme === newDocumentTheme)
return;

if (oldTheme)
document.documentElement.classList.remove(oldTheme);
document.documentElement.classList.add(newTheme);
settings.setString('theme', newTheme);
if (oldDocumentTheme)
document.documentElement.classList.remove(oldDocumentTheme);
document.documentElement.classList.add(newDocumentTheme);
for (const listener of listeners)
listener(newTheme);
listener(newDocumentTheme);
}

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

export function removeThemeListener(listener: (theme: Theme) => void) {
export function removeThemeListener(listener: (theme: DocumentTheme) => void) {
listeners.delete(listener);
}

export function currentTheme(): Theme {
return document.documentElement.classList.contains('dark-mode') ? 'dark-mode' : 'light-mode';
function currentTheme(): Theme {
return settings.getString(kThemeSettingsKey, kDefaultTheme);
}

export function useDarkModeSetting(): [boolean, (value: boolean) => void] {
const [theme, setTheme] = React.useState(currentTheme() === 'dark-mode');
return [theme, (value: boolean) => {
const current = currentTheme() === 'dark-mode';
if (current !== value)
toggleTheme();
setTheme(value);
}];
export function currentDocumentTheme(): DocumentTheme | null {
if (document.documentElement.classList.contains('dark-mode'))
return 'dark-mode';
if (document.documentElement.classList.contains('light-mode'))
return 'light-mode';
return null;
}

export function useThemeSetting(): [Theme, (value: Theme) => void] {
const [theme, setTheme] = React.useState<Theme>(currentTheme());

React.useEffect(() => {
settings.setString(kThemeSettingsKey, theme);
updateDocumentTheme(theme);
}, [theme]);

return [theme, setTheme];
}
4 changes: 2 additions & 2 deletions packages/web/src/uiUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,11 +209,11 @@ declare global {
export class Settings {
onChangeEmitter = new EventTarget();

getString(name: string, defaultValue: string): string {
getString<T extends string>(name: string, defaultValue: T): T {
return localStorage[name] || defaultValue;
}

setString(name: string, value: string) {
setString<T extends string>(name: string, value: T) {
localStorage[name] = value;
this.onChangeEmitter.dispatchEvent(new Event(name));
window.saveSettings?.();
Expand Down
4 changes: 2 additions & 2 deletions tests/config/traceViewerFixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ class TraceViewerPage {
sourceCodeTab: Locator;

settingsDialog: Locator;
darkModeSetting: Locator;
themeSetting: Locator;
displayCanvasContentSetting: Locator;

constructor(public page: Page) {
Expand All @@ -67,7 +67,7 @@ class TraceViewerPage {
this.sourceCodeTab = page.getByRole('tabpanel', { name: 'Source' });

this.settingsDialog = page.getByTestId('settings-toolbar-dialog');
this.darkModeSetting = page.locator('.setting').getByText('Dark mode');
this.themeSetting = this.settingsDialog.getByRole('combobox', { name: 'Theme' });
this.displayCanvasContentSetting = page.locator('.setting').getByText('Display canvas content');
}

Expand Down
Loading