New theme ui in user settings (#12576)

* Add hook to get the theme

* Adapt subsection settings to new ui

* WIP new theme subsection

* Add theme selection

* Fix test types

* Disabled theme selector when system theme is used

* Update compound to `4.4.1`

* Add custom theme support

* Remove old ThemChoicePanel

* Fix QuickThemeSwitcher-test.tsx

* Fix AppearanceUserSettingsTab-test.tsx

* Update i18n

* Fix ThemeChoicePanel-test.tsx

* Update `@vector-im/compound-web`

* Small tweaks

* Fix CSS comments and use compound variable

* Remove custom theme title

* i18n: update

* test: add tests to theme selection

* test: update AppearanceUserSettingsTab-test snapshot

* test: rework custom theme

* playwright: fix audio-player.spec.ts

* playwright: appearance tab

* test: update snapshot

* playright: add custom theme

* i18n: use correct char for ellipsis

* a11y: add missing aria-label to delete button

* dialog: update close button tooltip

* theme: remove local state and handle custom delete

* theme: don't add twice the same custom theme

* test: update snapshot

* playwright: update snapshot

* custom theme: add background to custom theme list

* update compound web

* Use new destructive property on `IconButton` of theme panel

* test: update snapshots

* rename new ui into legacy

* remove wrong constructor doc

* fix theme selector padding

* theme selector: fix key

* test: fix e2e
This commit is contained in:
Florian Duros 2024-06-26 17:47:01 +02:00 committed by GitHub
parent 8ede89101a
commit 33a017b528
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1749 additions and 477 deletions

View file

@ -160,7 +160,7 @@ test.describe("Audio player", () => {
// Enable high contrast manually
const settings = await app.settings.openUserSettings("Appearance");
await settings.getByTestId("mx_ThemeChoicePanel").getByText("Use high contrast").click();
await settings.getByRole("radio", { name: "High contrast" }).click();
await app.closeDialog();

View file

@ -14,8 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { test, expect } from "../../element-web-test";
import { SettingLevel } from "../../../src/settings/SettingLevel";
import { expect, test } from ".";
test.describe("Appearance user settings tab", () => {
test.use({
@ -151,69 +150,68 @@ test.describe("Appearance user settings tab", () => {
});
test.describe("Theme Choice Panel", () => {
test.beforeEach(async ({ app, user }) => {
test.beforeEach(async ({ app, user, util }) => {
// Disable the default theme for consistency in case ThemeWatcher automatically chooses it
await app.settings.setValue("use_system_theme", null, SettingLevel.DEVICE, false);
await util.disableSystemTheme();
await util.openAppearanceTab();
});
test("should be rendered with the light theme selected", async ({ page, app }) => {
await app.settings.openUserSettings("Appearance");
const themePanel = page.getByTestId("mx_ThemeChoicePanel");
const useSystemTheme = themePanel.getByTestId("checkbox-use-system-theme");
await expect(useSystemTheme.getByText("Match system theme")).toBeVisible();
test("should be rendered with the light theme selected", async ({ page, app, util }) => {
// Assert that 'Match system theme' is not checked
// Note that mx_Checkbox_checkmark exists and is hidden by CSS if it is not checked
await expect(useSystemTheme.locator(".mx_Checkbox_checkmark")).not.toBeVisible();
await expect(util.getMatchSystemThemeCheckbox()).not.toBeChecked();
const selectors = themePanel.getByTestId("theme-choice-panel-selectors");
await expect(selectors.locator(".mx_ThemeSelector_light")).toBeVisible();
await expect(selectors.locator(".mx_ThemeSelector_dark")).toBeVisible();
// Assert that the light theme is selected
await expect(selectors.locator(".mx_ThemeSelector_light.mx_StyledRadioButton_enabled")).toBeVisible();
// Assert that the buttons for the light and dark theme are not enabled
await expect(selectors.locator(".mx_ThemeSelector_light.mx_StyledRadioButton_disabled")).not.toBeVisible();
await expect(selectors.locator(".mx_ThemeSelector_dark.mx_StyledRadioButton_disabled")).not.toBeVisible();
await expect(util.getLightTheme()).toBeChecked();
// Assert that the dark and high contrast themes are not selected
await expect(util.getDarkTheme()).not.toBeChecked();
await expect(util.getHighContrastTheme()).not.toBeChecked();
// Assert that the checkbox for the high contrast theme is rendered
await expect(themePanel.locator(".mx_Checkbox", { hasText: "Use high contrast" })).toBeVisible();
await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-light.png");
});
test("should disable the labels for themes and the checkbox for the high contrast theme if the checkbox for the system theme is clicked", async ({
page,
app,
}) => {
await app.settings.openUserSettings("Appearance");
const themePanel = page.getByTestId("mx_ThemeChoicePanel");
test("should disable the themes when the system theme is clicked", async ({ page, app, util }) => {
await util.getMatchSystemThemeCheckbox().click();
await themePanel.locator(".mx_Checkbox", { hasText: "Match system theme" }).click();
// Assert that the themes are disabled
await expect(util.getLightTheme()).toBeDisabled();
await expect(util.getDarkTheme()).toBeDisabled();
await expect(util.getHighContrastTheme()).toBeDisabled();
// Assert that the labels for the light theme and dark theme are disabled
await expect(themePanel.locator(".mx_ThemeSelector_light.mx_StyledRadioButton_disabled")).toBeVisible();
await expect(themePanel.locator(".mx_ThemeSelector_dark.mx_StyledRadioButton_disabled")).toBeVisible();
// Assert that there does not exist a label for an enabled theme
await expect(themePanel.locator("label.mx_StyledRadioButton_enabled")).not.toBeVisible();
// Assert that the checkbox and label to enable the high contrast theme should not exist
await expect(themePanel.locator(".mx_Checkbox", { hasText: "Use high contrast" })).not.toBeVisible();
await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-match-system-enabled.png");
});
test("should not render the checkbox and the label for the high contrast theme if the dark theme is selected", async ({
page,
app,
}) => {
await app.settings.openUserSettings("Appearance");
const themePanel = page.getByTestId("mx_ThemeChoicePanel");
test("should change the theme to dark", async ({ page, app, util }) => {
// Assert that the light theme is selected
await expect(util.getLightTheme()).toBeChecked();
// Assert that the checkbox and the label to enable the high contrast theme should exist
await expect(themePanel.locator(".mx_Checkbox", { hasText: "Use high contrast" })).toBeVisible();
await util.getDarkTheme().click();
// Enable the dark theme
await themePanel.locator(".mx_ThemeSelector_dark").click();
// Assert that the light and high contrast themes are not selected
await expect(util.getLightTheme()).not.toBeChecked();
await expect(util.getDarkTheme()).toBeChecked();
await expect(util.getHighContrastTheme()).not.toBeChecked();
// Assert that the checkbox and the label should not exist
await expect(themePanel.locator(".mx_Checkbox", { hasText: "Use high contrast" })).not.toBeVisible();
await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-dark.png");
});
test.describe("custom theme", () => {
test.use({
labsFlags: ["feature_custom_themes"],
});
test("should render the custom theme section", async ({ page, app, util }) => {
await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-custom-theme.png");
});
test("should be able to add and remove a custom theme", async ({ page, app, util }) => {
await util.addCustomTheme();
await expect(util.getCustomTheme()).not.toBeChecked();
await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-custom-theme-added.png");
await util.removeCustomTheme();
await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-custom-theme.png");
});
});
});
});

View file

@ -0,0 +1,139 @@
/*
* Copyright 2024 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Page } from "@playwright/test";
import { ElementAppPage } from "../../../pages/ElementAppPage";
import { test as base, expect } from "../../../element-web-test";
import { SettingLevel } from "../../../../src/settings/SettingLevel";
export { expect };
/**
* Set up for the appearance tab test
*/
export const test = base.extend<{
util: Helpers;
}>({
util: async ({ page, app }, use) => {
await use(new Helpers(page, app));
},
});
/**
* A collection of helper functions for the appearance tab test
* The goal is to make easier to get and interact with the button, input, or other elements of the appearance tab
*/
class Helpers {
private CUSTOM_THEME_URL = "http://custom.theme";
private CUSTOM_THEME = {
name: "Custom theme",
isDark: false,
colors: {},
};
constructor(
private page: Page,
private app: ElementAppPage,
) {}
/**
* Open the appearance tab
*/
openAppearanceTab() {
return this.app.settings.openUserSettings("Appearance");
}
// Theme Panel
/**
* Disable in the settings the system theme
*/
disableSystemTheme() {
return this.app.settings.setValue("use_system_theme", null, SettingLevel.DEVICE, false);
}
/**
* Return the theme section
*/
getThemePanel() {
return this.page.getByTestId("themePanel");
}
/**
* Return the system theme toggle
*/
getMatchSystemThemeCheckbox() {
return this.getThemePanel().getByRole("checkbox");
}
/**
* Return the theme radio button
* @param theme - the theme to select
* @private
*/
private getThemeRadio(theme: string) {
return this.getThemePanel().getByRole("radio", { name: theme });
}
/**
* Return the light theme radio button
*/
getLightTheme() {
return this.getThemeRadio("Light");
}
/**
* Return the dark theme radio button
*/
getDarkTheme() {
return this.getThemeRadio("Dark");
}
/**
* Return the custom theme radio button
*/
getCustomTheme() {
return this.getThemeRadio(this.CUSTOM_THEME.name);
}
/**
* Return the high contrast theme radio button
*/
getHighContrastTheme() {
return this.getThemeRadio("High contrast");
}
/**
* Add a custom theme
* Mock the request to the custom and return a fake local custom theme
*/
async addCustomTheme() {
await this.page.route(this.CUSTOM_THEME_URL, (route) =>
route.fulfill({ body: JSON.stringify(this.CUSTOM_THEME) }),
);
await this.page.getByRole("textbox", { name: "Add custom theme" }).fill(this.CUSTOM_THEME_URL);
await this.page.getByRole("button", { name: "Add custom theme" }).click();
await this.page.unroute(this.CUSTOM_THEME_URL);
}
/**
* Remove the custom theme
*/
removeCustomTheme() {
return this.getThemePanel().getByRole("listitem", { name: this.CUSTOM_THEME.name }).getByRole("button").click();
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

View file

@ -604,7 +604,7 @@ legend {
.mx_Dialog
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button
),
):not(.mx_ThemeChoicePanel_CustomTheme button),
.mx_Dialog input[type="submit"],
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton),
.mx_Dialog_buttons input[type="submit"] {
@ -624,14 +624,14 @@ legend {
.mx_Dialog
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button
):last-child {
):not(.mx_ThemeChoicePanel_CustomTheme button):last-child {
margin-right: 0px;
}
.mx_Dialog
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button
):focus,
):not(.mx_ThemeChoicePanel_CustomTheme button):focus,
.mx_Dialog input[type="submit"]:focus,
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):focus,
.mx_Dialog_buttons input[type="submit"]:focus {
@ -643,7 +643,7 @@ legend {
.mx_Dialog_buttons
button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button
),
):not(.mx_ThemeChoicePanel_CustomTheme button),
.mx_Dialog_buttons input[type="submit"].mx_Dialog_primary {
color: var(--cpd-color-text-on-solid-primary);
background-color: var(--cpd-color-bg-action-primary-rest);
@ -654,7 +654,9 @@ legend {
.mx_Dialog button.danger:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]),
.mx_Dialog input[type="submit"].danger,
.mx_Dialog_buttons
button.danger:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(.mx_UserProfileSettings button),
button.danger:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(.mx_UserProfileSettings button):not(
.mx_ThemeChoicePanel_CustomTheme button
),
.mx_Dialog_buttons input[type="submit"].danger {
background-color: var(--cpd-color-bg-critical-primary);
border: solid 1px var(--cpd-color-bg-critical-primary);
@ -670,7 +672,7 @@ legend {
.mx_Dialog
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button
):disabled,
):not(.mx_ThemeChoicePanel_CustomTheme button):disabled,
.mx_Dialog input[type="submit"]:disabled,
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):disabled,
.mx_Dialog_buttons input[type="submit"]:disabled {

View file

@ -17,6 +17,12 @@ limitations under the License.
.mx_SettingsSubsection {
width: 100%;
box-sizing: border-box;
&.mx_SettingsSubsection_newUi {
display: flex;
flex-direction: column;
gap: var(--cpd-space-8x);
}
}
.mx_SettingsSubsection_description {
@ -54,4 +60,8 @@ limitations under the License.
&.mx_SettingsSubsection_noHeading {
margin-top: 0;
}
&.mx_SettingsSubsection_content_newUi {
gap: var(--cpd-space-6x);
margin-top: 0;
}
}

View file

@ -14,48 +14,72 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_ThemeChoicePanel_themeSelectors {
color: $primary-content;
.mx_ThemeChoicePanel_ThemeSelectors {
display: flex;
flex-direction: row;
flex-wrap: wrap;
/* Override form default style */
flex-direction: row !important;
gap: var(--cpd-space-4x) !important;
> .mx_StyledRadioButton {
align-items: center;
padding: $font-16px;
box-sizing: border-box;
border-radius: 10px;
width: 180px;
.mx_ThemeChoicePanel_themeSelector {
border: 1px solid var(--cpd-color-border-interactive-secondary);
border-radius: var(--cpd-space-1-5x);
padding: var(--cpd-space-3x) var(--cpd-space-5x) var(--cpd-space-3x) var(--cpd-space-3x);
gap: var(--cpd-space-2x);
background-color: var(--cpd-color-bg-canvas-default);
background: $accent-200;
opacity: 0.4;
flex-shrink: 1;
flex-grow: 0;
margin-right: 15px;
margin-top: 10px;
font-weight: var(--cpd-font-weight-semibold);
> span {
justify-content: center;
}
}
> .mx_StyledRadioButton_enabled {
opacity: 1;
/* These colors need to be hardcoded because they don't change with the theme */
&.mx_ThemeSelector_light {
background-color: #f3f8fd;
color: #2e2f32;
&.mx_ThemeChoicePanel_themeSelector_enabled {
border-color: var(--cpd-color-border-interactive-primary);
}
&.mx_ThemeSelector_dark {
/* 5% lightened version of 181b21 */
background-color: #25282e;
color: #f3f8fd;
&.mx_ThemeChoicePanel_themeSelector_disabled {
border-color: var(--cpd-color-border-disabled);
}
.mx_ThemeChoicePanel_themeSelector_Label {
color: var(--cpd-color-text-primary);
font: var(--cpd-font-body-md-semibold);
}
}
}
.mx_ThemeChoicePanel_CustomTheme {
width: 100%;
display: flex;
flex-direction: column;
gap: var(--cpd-space-4x);
.mx_ThemeChoicePanel_CustomTheme_EditInPlace input:focus {
/*
* When the input is focused, the border is growing
* We need to move it a bit to avoid the left border to be under the left panel
*/
margin-left: var(--cpd-space-0-5x);
}
.mx_ThemeChoicePanel_CustomThemeList {
display: flex;
flex-direction: column;
gap: var(--cpd-space-4x);
/*
* Override the default padding/margin of the list
*/
padding: 0;
margin: 0;
.mx_ThemeChoicePanel_CustomThemeList_theme {
display: flex;
justify-content: space-between;
align-items: center;
background-color: var(--cpd-color-gray-200);
padding: var(--cpd-space-2x) var(--cpd-space-2x) var(--cpd-space-2x) var(--cpd-space-4x);
.mx_ThemeChoicePanel_CustomThemeList_name {
font: var(--cpd-font-body-sm-semibold);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}

View file

@ -128,7 +128,8 @@ export default class BaseDialog extends React.Component<IProps> {
onClick={this.onCancelClick}
className="mx_Dialog_cancelButton"
aria-label={_t("dialog_close_label")}
title={_t("dialog_close_label")}
title={_t("action|close")}
placement="bottom"
/>
);
}

View file

@ -1,285 +1,340 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
* Copyright 2024 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, { ChangeEvent, JSX, useCallback, useMemo, useRef, useState } from "react";
import {
InlineField,
ToggleControl,
Label,
Root,
RadioControl,
EditInPlace,
IconButton,
} from "@vector-im/compound-web";
import { Icon as DeleteIcon } from "@vector-im/compound-design-tokens/icons/delete.svg";
import classNames from "classnames";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore";
import { findHighContrastTheme, findNonHighContrastTheme, getOrderedThemes, isHighContrastTheme } from "../../../theme";
import SettingsSubsection from "./shared/SettingsSubsection";
import ThemeWatcher from "../../../settings/watchers/ThemeWatcher";
import AccessibleButton from "../elements/AccessibleButton";
import SettingsStore from "../../../settings/SettingsStore";
import { SettingLevel } from "../../../settings/SettingLevel";
import dis from "../../../dispatcher/dispatcher";
import { RecheckThemePayload } from "../../../dispatcher/payloads/RecheckThemePayload";
import { Action } from "../../../dispatcher/actions";
import StyledCheckbox from "../elements/StyledCheckbox";
import Field from "../elements/Field";
import StyledRadioGroup from "../elements/StyledRadioGroup";
import { SettingLevel } from "../../../settings/SettingLevel";
import PosthogTrackers from "../../../PosthogTrackers";
import SettingsSubsection from "./shared/SettingsSubsection";
import { useTheme } from "../../../hooks/useTheme";
import { findHighContrastTheme, getOrderedThemes, CustomTheme as CustomThemeType, ITheme } from "../../../theme";
import { useSettingValue } from "../../../hooks/useSettings";
interface IProps {}
/**
* Panel to choose the theme
*/
export function ThemeChoicePanel(): JSX.Element {
const themeState = useTheme();
const themeWatcher = useRef(new ThemeWatcher());
const customThemeEnabled = useSettingValue<boolean>("feature_custom_themes");
interface IThemeState {
return (
<SettingsSubsection heading={_t("common|theme")} legacy={false} data-testid="themePanel">
{themeWatcher.current.isSystemThemeSupported() && (
<SystemTheme systemThemeActivated={themeState.systemThemeActivated} />
)}
<ThemeSelectors theme={themeState.theme} disabled={themeState.systemThemeActivated} />
{customThemeEnabled && <CustomTheme theme={themeState.theme} />}
</SettingsSubsection>
);
}
/**
* Component to toggle the system theme
*/
interface SystemThemeProps {
/* Whether the system theme is activated */
systemThemeActivated: boolean;
}
/**
* Component to toggle the system theme
*/
function SystemTheme({ systemThemeActivated }: SystemThemeProps): JSX.Element {
return (
<Root
onChange={async (evt) => {
const checked = new FormData(evt.currentTarget).get("systemTheme") === "on";
await SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, checked);
dis.dispatch<RecheckThemePayload>({ action: Action.RecheckTheme });
}}
>
<InlineField
name="systemTheme"
control={<ToggleControl name="systemTheme" defaultChecked={systemThemeActivated} />}
>
<Label>{SettingsStore.getDisplayName("use_system_theme")}</Label>
</InlineField>
</Root>
);
}
/**
* Component to select the theme
*/
interface ThemeSelectorProps {
/* The current theme */
theme: string;
useSystemTheme: boolean;
/* The theme can't be selected */
disabled: boolean;
}
export interface CustomThemeMessage {
isError: boolean;
text: string;
}
/**
* Component to select the theme
*/
function ThemeSelectors({ theme, disabled }: ThemeSelectorProps): JSX.Element {
const themes = useThemes();
interface IState extends IThemeState {
customThemeUrl: string;
customThemeMessage: CustomThemeMessage;
}
return (
<Root
className="mx_ThemeChoicePanel_ThemeSelectors"
onChange={async (evt) => {
// We don't have any file in the form, we can cast it as string safely
const newTheme = new FormData(evt.currentTarget).get("themeSelector") as string | null;
export default class ThemeChoicePanel extends React.Component<IProps, IState> {
private themeTimer?: number;
// Do nothing if the same theme is selected
if (!newTheme || theme === newTheme) return;
public constructor(props: IProps) {
super(props);
this.state = {
...ThemeChoicePanel.calculateThemeState(),
customThemeUrl: "",
customThemeMessage: { isError: false, text: "" },
};
}
public static calculateThemeState(): IThemeState {
// We have to mirror the logic from ThemeWatcher.getEffectiveTheme so we
// show the right values for things.
const themeChoice: string = SettingsStore.getValue("theme");
const systemThemeExplicit: boolean = SettingsStore.getValueAt(
SettingLevel.DEVICE,
"use_system_theme",
null,
false,
true,
);
const themeExplicit: string = SettingsStore.getValueAt(SettingLevel.DEVICE, "theme", null, false, true);
// If the user has enabled system theme matching, use that.
if (systemThemeExplicit) {
return {
theme: themeChoice,
useSystemTheme: true,
};
}
// If the user has set a theme explicitly, use that (no system theme matching)
if (themeExplicit) {
return {
theme: themeChoice,
useSystemTheme: false,
};
}
// Otherwise assume the defaults for the settings
return {
theme: themeChoice,
useSystemTheme: SettingsStore.getValueAt(SettingLevel.DEVICE, "use_system_theme"),
};
}
private onThemeChange = (newTheme: string): void => {
if (this.state.theme === newTheme) return;
PosthogTrackers.trackInteraction("WebSettingsAppearanceTabThemeSelector");
// doing getValue in the .catch will still return the value we failed to set,
// so remember what the value was before we tried to set it so we can revert
const oldTheme: string = SettingsStore.getValue("theme");
SettingsStore.setValue("theme", null, SettingLevel.DEVICE, newTheme).catch(() => {
dis.dispatch<RecheckThemePayload>({ action: Action.RecheckTheme });
this.setState({ theme: oldTheme });
});
this.setState({ theme: newTheme });
// The settings watcher doesn't fire until the echo comes back from the
// server, so to make the theme change immediately we need to manually
// do the dispatch now
// XXX: The local echoed value appears to be unreliable, in particular
// when settings custom themes(!) so adding forceTheme to override
// the value from settings.
dis.dispatch<RecheckThemePayload>({ action: Action.RecheckTheme, forceTheme: newTheme });
};
private onUseSystemThemeChanged = (checked: boolean): void => {
this.setState({ useSystemTheme: checked });
SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, checked);
dis.dispatch<RecheckThemePayload>({ action: Action.RecheckTheme });
};
private onAddCustomTheme = async (): Promise<void> => {
let currentThemes: string[] = SettingsStore.getValue("custom_themes");
if (!currentThemes) currentThemes = [];
currentThemes = currentThemes.map((c) => c); // cheap clone
if (this.themeTimer) {
clearTimeout(this.themeTimer);
}
try {
const r = await fetch(this.state.customThemeUrl);
// XXX: need some schema for this
const themeInfo = await r.json();
if (!themeInfo || typeof themeInfo["name"] !== "string" || typeof themeInfo["colors"] !== "object") {
this.setState({
customThemeMessage: { text: _t("settings|appearance|custom_theme_invalid"), isError: true },
// doing getValue in the .catch will still return the value we failed to set,
SettingsStore.setValue("theme", null, SettingLevel.DEVICE, newTheme).catch(() => {
dis.dispatch<RecheckThemePayload>({ action: Action.RecheckTheme });
});
return;
}
currentThemes.push(themeInfo);
} catch (e) {
logger.error(e);
this.setState({
customThemeMessage: { text: _t("settings|appearance|custom_theme_error_downloading"), isError: true },
});
return; // Don't continue on error
}
await SettingsStore.setValue("custom_themes", null, SettingLevel.ACCOUNT, currentThemes);
this.setState({
customThemeUrl: "",
customThemeMessage: { text: _t("settings|appearance|custom_theme_success"), isError: false },
});
this.themeTimer = window.setTimeout(() => {
this.setState({ customThemeMessage: { text: "", isError: false } });
}, 3000);
};
private onCustomThemeChange = (e: React.ChangeEvent<HTMLSelectElement | HTMLInputElement>): void => {
this.setState({ customThemeUrl: e.target.value });
};
private renderHighContrastCheckbox(): React.ReactElement<HTMLDivElement> | undefined {
if (
!this.state.useSystemTheme &&
(findHighContrastTheme(this.state.theme) || isHighContrastTheme(this.state.theme))
) {
return (
<div>
<StyledCheckbox
checked={isHighContrastTheme(this.state.theme)}
onChange={(e) => this.highContrastThemeChanged(e.target.checked)}
// The settings watcher doesn't fire until the echo comes back from the
// server, so to make the theme change immediately we need to manually
// do the dispatch now
// XXX: The local echoed value appears to be unreliable, in particular
// when settings custom themes(!) so adding forceTheme to override
// the value from settings.
dis.dispatch<RecheckThemePayload>({ action: Action.RecheckTheme, forceTheme: newTheme });
}}
>
{themes.map((_theme) => {
const isChecked = theme === _theme.id;
return (
<InlineField
className={classNames("mx_ThemeChoicePanel_themeSelector", {
[`mx_ThemeChoicePanel_themeSelector_enabled`]: !disabled && theme === _theme.id,
[`mx_ThemeChoicePanel_themeSelector_disabled`]: disabled,
// We need to force the compound theme to be light or dark
// The theme selection doesn't depend on the current theme
// For example when the light theme is used, the dark theme selector should be dark
"cpd-theme-light": !_theme.isDark,
"cpd-theme-dark": _theme.isDark,
})}
name="themeSelector"
key={_theme.id}
control={
<RadioControl
name="themeSelector"
checked={!disabled && isChecked}
disabled={disabled}
value={_theme.id}
/>
}
>
{_t("settings|appearance|use_high_contrast")}
</StyledCheckbox>
</div>
);
}
}
<Label className="mx_ThemeChoicePanel_themeSelector_Label">{_theme.name}</Label>
</InlineField>
);
})}
</Root>
);
}
private highContrastThemeChanged(checked: boolean): void {
let newTheme: string | undefined;
if (checked) {
newTheme = findHighContrastTheme(this.state.theme);
} else {
newTheme = findNonHighContrastTheme(this.state.theme);
}
if (newTheme) {
this.onThemeChange(newTheme);
}
}
public render(): React.ReactElement<HTMLDivElement> {
const themeWatcher = new ThemeWatcher();
let systemThemeSection: JSX.Element | undefined;
if (themeWatcher.isSystemThemeSupported()) {
systemThemeSection = (
<div data-testid="checkbox-use-system-theme">
<StyledCheckbox
checked={this.state.useSystemTheme}
onChange={(e) => this.onUseSystemThemeChanged(e.target.checked)}
>
{SettingsStore.getDisplayName("use_system_theme")}
</StyledCheckbox>
</div>
);
}
let customThemeForm: JSX.Element | undefined;
if (SettingsStore.getValue("feature_custom_themes")) {
let messageElement: JSX.Element | undefined;
if (this.state.customThemeMessage.text) {
if (this.state.customThemeMessage.isError) {
messageElement = <div className="text-error">{this.state.customThemeMessage.text}</div>;
} else {
messageElement = <div className="text-success">{this.state.customThemeMessage.text}</div>;
}
}
customThemeForm = (
<div className="mx_SettingsTab_section">
<form onSubmit={this.onAddCustomTheme}>
<Field
label={_t("settings|appearance|custom_theme_url")}
type="text"
id="mx_GeneralUserSettingsTab_customThemeInput"
autoComplete="off"
onChange={this.onCustomThemeChange}
value={this.state.customThemeUrl}
/>
<AccessibleButton
onClick={this.onAddCustomTheme}
type="submit"
kind="primary_sm"
disabled={!this.state.customThemeUrl.trim()}
>
{_t("settings|appearance|custom_theme_add_button")}
</AccessibleButton>
{messageElement}
</form>
</div>
);
}
const orderedThemes = getOrderedThemes();
return (
<SettingsSubsection heading={_t("common|theme")} data-testid="mx_ThemeChoicePanel">
{systemThemeSection}
<div className="mx_ThemeChoicePanel_themeSelectors" data-testid="theme-choice-panel-selectors">
<StyledRadioGroup
name="theme"
definitions={orderedThemes.map((t) => ({
value: t.id,
label: t.name,
disabled: this.state.useSystemTheme,
className: "mx_ThemeSelector_" + t.id,
}))}
onChange={this.onThemeChange}
value={this.apparentSelectedThemeId()}
outlined
/>
</div>
{this.renderHighContrastCheckbox()}
{customThemeForm}
</SettingsSubsection>
/**
* Return all the available themes
*/
function useThemes(): Array<ITheme & { isDark: boolean }> {
const customThemes = useSettingValue<CustomThemeType[] | undefined>("custom_themes");
return useMemo(() => {
// Put the custom theme into a map
// To easily find the theme by name when going through the themes list
const checkedCustomThemes = customThemes || [];
const customThemeMap = checkedCustomThemes.reduce(
(map, theme) => map.set(theme.name, theme),
new Map<string, CustomThemeType>(),
);
}
public apparentSelectedThemeId(): string | undefined {
if (this.state.useSystemTheme) {
return undefined;
}
const nonHighContrast = findNonHighContrastTheme(this.state.theme);
return nonHighContrast ? nonHighContrast : this.state.theme;
const themes = getOrderedThemes();
// Separate the built-in themes from the custom themes
// To insert the high contrast theme between them
const builtInThemes = themes.filter((theme) => !customThemeMap.has(theme.name));
const otherThemes = themes.filter((theme) => customThemeMap.has(theme.name));
const highContrastTheme = makeHighContrastTheme();
if (highContrastTheme) builtInThemes.push(highContrastTheme);
const allThemes = builtInThemes.concat(otherThemes);
// Check if the themes are dark
return allThemes.map((theme) => {
const customTheme = customThemeMap.get(theme.name);
const isDark = (customTheme ? customTheme.is_dark : theme.id.includes("dark")) || false;
return { ...theme, isDark };
});
}, [customThemes]);
}
/**
* Create the light high contrast theme
*/
function makeHighContrastTheme(): ITheme | undefined {
const lightHighContrastId = findHighContrastTheme("light");
if (lightHighContrastId) {
return {
name: _t("settings|appearance|high_contrast"),
id: lightHighContrastId,
};
}
}
interface CustomThemeProps {
/**
* The current theme
*/
theme: string;
}
/**
* Add and manager custom themes
*/
function CustomTheme({ theme }: CustomThemeProps): JSX.Element {
const [customTheme, setCustomTheme] = useState<string>("");
const [error, setError] = useState<string>();
const clear = useCallback(() => {
setError(undefined);
setCustomTheme("");
}, [setError, setCustomTheme]);
return (
<div className="mx_ThemeChoicePanel_CustomTheme">
<EditInPlace
className="mx_ThemeChoicePanel_CustomTheme_EditInPlace"
label={_t("settings|appearance|custom_theme_add")}
saveButtonLabel={_t("settings|appearance|custom_theme_add")}
savingLabel={_t("settings|appearance|custom_theme_downloading")}
helpLabel={_t("settings|appearance|custom_theme_help")}
error={error}
value={customTheme}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setError(undefined);
setCustomTheme(e.target.value);
}}
onSave={async () => {
// The field empty is empty
if (!customTheme) return;
// Get the custom themes and do a cheap clone
// To avoid to mutate the original array in the settings
const currentThemes =
SettingsStore.getValue<CustomThemeType[]>("custom_themes").map((t) => t) || [];
try {
const r = await fetch(customTheme);
// XXX: need some schema for this
const themeInfo = await r.json();
if (
!themeInfo ||
typeof themeInfo["name"] !== "string" ||
typeof themeInfo["colors"] !== "object"
) {
setError(_t("settings|appearance|custom_theme_invalid"));
return;
}
// Check if the theme is already existing
const isAlreadyExisting = Boolean(currentThemes.find((t) => t.name === themeInfo.name));
if (isAlreadyExisting) {
clear();
return;
}
currentThemes.push(themeInfo);
} catch (e) {
logger.error(e);
setError(_t("settings|appearance|custom_theme_error_downloading"));
return;
}
// Reset the error
clear();
await SettingsStore.setValue("custom_themes", null, SettingLevel.ACCOUNT, currentThemes);
}}
onCancel={clear}
/>
<CustomThemeList theme={theme} />
</div>
);
}
interface CustomThemeListProps {
/*
* The current theme
*/
theme: string;
}
/**
* List of the custom themes
*/
function CustomThemeList({ theme: currentTheme }: CustomThemeListProps): JSX.Element {
const customThemes = useSettingValue<CustomThemeType[]>("custom_themes") || [];
return (
<ul className="mx_ThemeChoicePanel_CustomThemeList">
{customThemes.map((theme) => {
return (
<li key={theme.name} className="mx_ThemeChoicePanel_CustomThemeList_theme" aria-label={theme.name}>
<span className="mx_ThemeChoicePanel_CustomThemeList_name">{theme.name}</span>
<IconButton
destructive={true}
aria-label={_t("action|delete")}
tooltip={_t("action|delete")}
onClick={async () => {
// Get the custom themes and do a cheap clone
// To avoid to mutate the original array in the settings
const currentThemes =
SettingsStore.getValue<CustomThemeType[]>("custom_themes").map((t) => t) || [];
// Remove the theme from the list
const newThemes = currentThemes.filter((t) => t.name !== theme.name);
await SettingsStore.setValue("custom_themes", null, SettingLevel.ACCOUNT, newThemes);
// If the delete custom theme is the current theme, reset the theme to the default theme
// By settings the theme at null at the device level, we are getting the default theme
if (currentTheme === `custom-${theme.name}`) {
await SettingsStore.setValue("theme", null, SettingLevel.DEVICE, null);
dis.dispatch<RecheckThemePayload>({
action: Action.RecheckTheme,
});
}
}}
>
<DeleteIcon />
</IconButton>
</li>
);
})}
</ul>
);
}

View file

@ -16,6 +16,7 @@ limitations under the License.
import classNames from "classnames";
import React, { HTMLAttributes } from "react";
import { Separator } from "@vector-im/compound-web";
import { SettingsSubsectionHeading } from "./SettingsSubsectionHeading";
@ -25,6 +26,11 @@ export interface SettingsSubsectionProps extends HTMLAttributes<HTMLDivElement>
children?: React.ReactNode;
// when true content will be justify-items: stretch, which will make items within the section stretch to full width.
stretchContent?: boolean;
/*
* When true, the legacy UI style will be applied to the subsection.
* @default true
*/
legacy?: boolean;
}
export const SettingsSubsectionText: React.FC<HTMLAttributes<HTMLDivElement>> = ({ children, ...rest }) => (
@ -38,10 +44,16 @@ export const SettingsSubsection: React.FC<SettingsSubsectionProps> = ({
description,
children,
stretchContent,
legacy = true,
...rest
}) => (
<div {...rest} className="mx_SettingsSubsection">
{typeof heading === "string" ? <SettingsSubsectionHeading heading={heading} /> : <>{heading}</>}
<div
{...rest}
className={classNames("mx_SettingsSubsection", {
mx_SettingsSubsection_newUi: !legacy,
})}
>
{typeof heading === "string" ? <SettingsSubsectionHeading heading={heading} legacy={legacy} /> : <>{heading}</>}
{!!description && (
<div className="mx_SettingsSubsection_description">
<SettingsSubsectionText>{description}</SettingsSubsectionText>
@ -52,11 +64,13 @@ export const SettingsSubsection: React.FC<SettingsSubsectionProps> = ({
className={classNames("mx_SettingsSubsection_content", {
mx_SettingsSubsection_contentStretch: !!stretchContent,
mx_SettingsSubsection_noHeading: !heading && !description,
mx_SettingsSubsection_content_newUi: !legacy,
})}
>
{children}
</div>
)}
{!legacy && <Separator />}
</div>
);

View file

@ -20,14 +20,24 @@ import Heading from "../../typography/Heading";
export interface SettingsSubsectionHeadingProps extends HTMLAttributes<HTMLDivElement> {
heading: string;
legacy?: boolean;
children?: React.ReactNode;
}
export const SettingsSubsectionHeading: React.FC<SettingsSubsectionHeadingProps> = ({ heading, children, ...rest }) => (
<div {...rest} className="mx_SettingsSubsectionHeading">
<Heading className="mx_SettingsSubsectionHeading_heading" size="4" as="h3">
{heading}
</Heading>
{children}
</div>
);
export const SettingsSubsectionHeading: React.FC<SettingsSubsectionHeadingProps> = ({
heading,
legacy = true,
children,
...rest
}) => {
const size = legacy ? "4" : "3";
return (
<div {...rest} className="mx_SettingsSubsectionHeading">
<Heading className="mx_SettingsSubsectionHeading_heading" size={size} as="h3">
{heading}
</Heading>
{children}
</div>
);
};

View file

@ -28,7 +28,7 @@ import { UIFeature } from "../../../../../settings/UIFeature";
import { Layout } from "../../../../../settings/enums/Layout";
import LayoutSwitcher from "../../LayoutSwitcher";
import FontScalingPanel from "../../FontScalingPanel";
import ThemeChoicePanel from "../../ThemeChoicePanel";
import { ThemeChoicePanel } from "../../ThemeChoicePanel";
import ImageSizePanel from "../../ImageSizePanel";
import SettingsTab from "../SettingsTab";
import { SettingsSection } from "../../shared/SettingsSection";

View file

@ -20,13 +20,13 @@ import { _t } from "../../../languageHandler";
import { Action } from "../../../dispatcher/actions";
import { findNonHighContrastTheme, getOrderedThemes } from "../../../theme";
import Dropdown from "../elements/Dropdown";
import ThemeChoicePanel from "../settings/ThemeChoicePanel";
import SettingsStore from "../../../settings/SettingsStore";
import { SettingLevel } from "../../../settings/SettingLevel";
import dis from "../../../dispatcher/dispatcher";
import { RecheckThemePayload } from "../../../dispatcher/payloads/RecheckThemePayload";
import PosthogTrackers from "../../../PosthogTrackers";
import { NonEmptyArray } from "../../../@types/common";
import { useTheme } from "../../../hooks/useTheme";
type Props = {
requestClose: () => void;
@ -37,10 +37,10 @@ const MATCH_SYSTEM_THEME_ID = "MATCH_SYSTEM_THEME_ID";
const QuickThemeSwitcher: React.FC<Props> = ({ requestClose }) => {
const orderedThemes = useMemo(getOrderedThemes, []);
const themeState = ThemeChoicePanel.calculateThemeState();
const themeState = useTheme();
const nonHighContrast = findNonHighContrastTheme(themeState.theme);
const theme = nonHighContrast ? nonHighContrast : themeState.theme;
const { useSystemTheme } = themeState;
const { systemThemeActivated } = themeState;
const themeOptions = [
{
@ -50,7 +50,7 @@ const QuickThemeSwitcher: React.FC<Props> = ({ requestClose }) => {
...orderedThemes,
];
const selectedTheme = useSystemTheme ? MATCH_SYSTEM_THEME_ID : theme;
const selectedTheme = systemThemeActivated ? MATCH_SYSTEM_THEME_ID : theme;
const onOptionChange = async (newTheme: string): Promise<void> => {
PosthogTrackers.trackInteraction("WebQuickSettingsThemeDropdown");

View file

@ -17,6 +17,7 @@ limitations under the License.
import { useEffect, useState } from "react";
import SettingsStore from "../settings/SettingsStore";
import { SettingLevel } from "../settings/SettingLevel";
// Hook to fetch the value of a setting and dynamically update when it changes
export const useSettingValue = <T>(settingName: string, roomId: string | null = null, excludeDefault = false): T => {
@ -35,6 +36,39 @@ export const useSettingValue = <T>(settingName: string, roomId: string | null =
return value;
};
/**
* Hook to fetch the value of a setting at a specific level and dynamically update when it changes
* @see SettingsStore.getValueAt
* @param level
* @param settingName
* @param roomId
* @param explicit
* @param excludeDefault
*/
export const useSettingValueAt = <T>(
level: SettingLevel,
settingName: string,
roomId: string | null = null,
explicit = false,
excludeDefault = false,
): T => {
const [value, setValue] = useState(
SettingsStore.getValueAt<T>(level, settingName, roomId, explicit, excludeDefault),
);
useEffect(() => {
const ref = SettingsStore.watchSetting(settingName, roomId, () => {
setValue(SettingsStore.getValueAt<T>(level, settingName, roomId, explicit, excludeDefault));
});
// clean-up
return () => {
SettingsStore.unwatchSetting(ref);
};
}, [level, settingName, roomId, explicit, excludeDefault]);
return value;
};
// Hook to fetch whether a feature is enabled and dynamically update when that changes
export const useFeatureEnabled = (featureName: string, roomId: string | null = null): boolean => {
const [enabled, setEnabled] = useState(SettingsStore.getValue<boolean>(featureName, roomId));

53
src/hooks/useTheme.ts Normal file
View file

@ -0,0 +1,53 @@
/*
* Copyright 2024 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { SettingLevel } from "../settings/SettingLevel";
import { useSettingValue, useSettingValueAt } from "./useSettings";
/**
* Hook to fetch the current theme and whether system theme matching is enabled.
*/
export function useTheme(): { theme: string; systemThemeActivated: boolean } {
// We have to mirror the logic from ThemeWatcher.getEffectiveTheme so we
// show the right values for things.
const themeChoice = useSettingValue<string>("theme");
const systemThemeExplicit = useSettingValueAt<string>(SettingLevel.DEVICE, "use_system_theme", null, false, true);
const themeExplicit = useSettingValueAt<string>(SettingLevel.DEVICE, "theme", null, false, true);
const systemThemeActivated = useSettingValue<boolean>("use_system_theme");
// If the user has enabled system theme matching, use that.
if (systemThemeExplicit) {
return {
theme: themeChoice,
systemThemeActivated: true,
};
}
// If the user has set a theme explicitly, use that (no system theme matching)
if (themeExplicit) {
return {
theme: themeChoice,
systemThemeActivated: false,
};
}
// Otherwise assume the defaults for the settings
return {
theme: themeChoice,
systemThemeActivated,
};
}

View file

@ -2420,21 +2420,21 @@
"custom_font_description": "Set the name of a font installed on your system & %(brand)s will attempt to use it.",
"custom_font_name": "System font name",
"custom_font_size": "Use custom size",
"custom_theme_add_button": "Add theme",
"custom_theme_error_downloading": "Error downloading theme information.",
"custom_theme_add": "Add custom theme",
"custom_theme_downloading": "Downloading custom theme…",
"custom_theme_error_downloading": "Error downloading theme",
"custom_theme_help": "Enter the URL of a custom theme you want to apply.",
"custom_theme_invalid": "Invalid theme schema.",
"custom_theme_success": "Theme added!",
"custom_theme_url": "Custom theme URL",
"dialog_title": "<strong>Settings:</strong> Appearance",
"font_size": "Font size",
"font_size_default": "%(fontSize)s (default)",
"high_contrast": "High contrast",
"image_size_default": "Default",
"image_size_large": "Large",
"layout_bubbles": "Message bubbles",
"layout_irc": "IRC (Experimental)",
"match_system_theme": "Match system theme",
"timeline_image_size": "Image size in the timeline",
"use_high_contrast": "Use high contrast"
"timeline_image_size": "Image size in the timeline"
},
"automatic_language_detection_syntax_highlight": "Enable automatic language detection for syntax highlighting",
"autoplay_gifs": "Autoplay GIFs",

View file

@ -355,7 +355,7 @@ export default class SettingsStore {
const setting = SETTINGS[settingName];
const levelOrder = getLevelOrder(setting);
return SettingsStore.getValueAt(levelOrder[0], settingName, roomId, false, excludeDefault);
return SettingsStore.getValueAt<T>(levelOrder[0], settingName, roomId, false, excludeDefault);
}
/**
@ -369,13 +369,13 @@ export default class SettingsStore {
* @param {boolean} excludeDefault True to disable using the default value.
* @return {*} The value, or null if not found.
*/
public static getValueAt(
public static getValueAt<T = any>(
level: SettingLevel,
settingName: string,
roomId: string | null = null,
explicit = false,
excludeDefault = false,
): any {
): T {
// Verify that the setting is actually a setting
const setting = SETTINGS[settingName];
if (!setting) {

View file

@ -103,7 +103,7 @@ export function enumerateThemes(): { [key: string]: string } {
return Object.assign({}, customThemeNames, BUILTIN_THEMES);
}
interface ITheme {
export interface ITheme {
id: string;
name: string;
}

View file

@ -15,15 +15,177 @@ limitations under the License.
*/
import React from "react";
import { render } from "@testing-library/react";
import { act, render, screen, waitFor } from "@testing-library/react";
import { mocked, MockedObject } from "jest-mock";
import userEvent from "@testing-library/user-event";
import fetchMock from "fetch-mock-jest";
import * as TestUtils from "../../../test-utils";
import ThemeChoicePanel from "../../../../src/components/views/settings/ThemeChoicePanel";
import { ThemeChoicePanel } from "../../../../src/components/views/settings/ThemeChoicePanel";
import SettingsStore from "../../../../src/settings/SettingsStore";
import ThemeWatcher from "../../../../src/settings/watchers/ThemeWatcher";
import { SettingLevel } from "../../../../src/settings/SettingLevel";
jest.mock("../../../../src/settings/watchers/ThemeWatcher");
describe("<ThemeChoicePanel />", () => {
/**
* Enable or disable the system theme
* @param enable
*/
async function enableSystemTheme(enable: boolean) {
await SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, enable);
}
/**
* Set the theme
* @param theme
*/
async function setTheme(theme: string) {
await SettingsStore.setValue("theme", null, SettingLevel.DEVICE, theme);
}
beforeEach(async () => {
mocked(ThemeWatcher).mockImplementation(() => {
return {
isSystemThemeSupported: jest.fn().mockReturnValue(true),
} as unknown as MockedObject<ThemeWatcher>;
});
await enableSystemTheme(false);
await setTheme("light");
});
describe("ThemeChoicePanel", () => {
it("renders the theme choice UI", () => {
TestUtils.stubClient();
const { asFragment } = render(<ThemeChoicePanel />);
expect(asFragment()).toMatchSnapshot();
});
describe("theme selection", () => {
describe("system theme", () => {
it("should disable Match system theme", async () => {
render(<ThemeChoicePanel />);
expect(screen.getByRole("checkbox", { name: "Match system theme" })).not.toBeChecked();
});
it("should enable Match system theme", async () => {
await enableSystemTheme(true);
render(<ThemeChoicePanel />);
expect(screen.getByRole("checkbox", { name: "Match system theme" })).toBeChecked();
});
it("should change the system theme when clicked", async () => {
jest.spyOn(SettingsStore, "setValue");
render(<ThemeChoicePanel />);
act(() => screen.getByRole("checkbox", { name: "Match system theme" }).click());
// The system theme should be enabled
expect(screen.getByRole("checkbox", { name: "Match system theme" })).toBeChecked();
expect(SettingsStore.setValue).toHaveBeenCalledWith("use_system_theme", null, "device", true);
});
});
describe("theme selection", () => {
it("should disable theme selection when system theme is enabled", async () => {
await enableSystemTheme(true);
render(<ThemeChoicePanel />);
// We expect all the themes to be disabled
const themes = screen.getAllByRole("radio");
themes.forEach((theme) => {
expect(theme).toBeDisabled();
});
});
it("should enable theme selection when system theme is disabled", async () => {
render(<ThemeChoicePanel />);
// We expect all the themes to be disabled
const themes = screen.getAllByRole("radio");
themes.forEach((theme) => {
expect(theme).not.toBeDisabled();
});
});
it("should have light theme selected", async () => {
render(<ThemeChoicePanel />);
// We expect the light theme to be selected
const lightTheme = screen.getByRole("radio", { name: "Light" });
expect(lightTheme).toBeChecked();
// And the dark theme shouldn't be selected
const darkTheme = screen.getByRole("radio", { name: "Dark" });
expect(darkTheme).not.toBeChecked();
});
it("should switch to dark theme", async () => {
jest.spyOn(SettingsStore, "setValue");
render(<ThemeChoicePanel />);
const darkTheme = screen.getByRole("radio", { name: "Dark" });
const lightTheme = screen.getByRole("radio", { name: "Light" });
expect(darkTheme).not.toBeChecked();
// Switch to the dark theme
act(() => darkTheme.click());
expect(SettingsStore.setValue).toHaveBeenCalledWith("theme", null, "device", "dark");
// Dark theme is now selected
await waitFor(() => expect(darkTheme).toBeChecked());
// Light theme is not selected anymore
expect(lightTheme).not.toBeChecked();
// The setting should be updated
expect(SettingsStore.setValue).toHaveBeenCalledWith("theme", null, "device", "dark");
});
});
});
describe("custom theme", () => {
const aliceTheme = { name: "Alice theme", is_dark: true, colors: {} };
const bobTheme = { name: "Bob theme", is_dark: false, colors: {} };
beforeEach(async () => {
await SettingsStore.setValue("feature_custom_themes", null, SettingLevel.DEVICE, true);
await SettingsStore.setValue("custom_themes", null, SettingLevel.DEVICE, [aliceTheme]);
});
it("should render the custom theme section", () => {
const { asFragment } = render(<ThemeChoicePanel />);
expect(asFragment()).toMatchSnapshot();
});
it("should add a custom theme", async () => {
jest.spyOn(SettingsStore, "setValue");
// Respond to the theme request
fetchMock.get("http://bob.theme", {
body: bobTheme,
});
render(<ThemeChoicePanel />);
// Add the new custom theme
const customThemeInput = screen.getByRole("textbox", { name: "Add custom theme" });
await userEvent.type(customThemeInput, "http://bob.theme");
screen.getByRole("button", { name: "Add custom theme" }).click();
// The new custom theme is added to the user's themes
await waitFor(() =>
expect(SettingsStore.setValue).toHaveBeenCalledWith("custom_themes", null, "account", [
aliceTheme,
bobTheme,
]),
);
});
it("should display custom theme", () => {
const { asFragment } = render(<ThemeChoicePanel />);
expect(screen.getByRole("radio", { name: aliceTheme.name })).toBeInTheDocument();
expect(screen.getByRole("listitem", { name: aliceTheme.name })).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
});
});
});

View file

@ -1,73 +1,774 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ThemeChoicePanel renders the theme choice UI 1`] = `
exports[`<ThemeChoicePanel /> custom theme should display custom theme 1`] = `
<DocumentFragment>
<div
class="mx_SettingsSubsection"
data-testid="mx_ThemeChoicePanel"
class="mx_SettingsSubsection mx_SettingsSubsection_newUi"
data-testid="themePanel"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
class="mx_Heading_h3 mx_SettingsSubsectionHeading_heading"
>
Theme
</h3>
</div>
<div
class="mx_SettingsSubsection_content"
class="mx_SettingsSubsection_content mx_SettingsSubsection_content_newUi"
>
<div
class="mx_ThemeChoicePanel_themeSelectors"
data-testid="theme-choice-panel-selectors"
<form
class="_root_148br_24"
>
<label
class="mx_StyledRadioButton mx_ThemeSelector_light mx_StyledRadioButton_disabled mx_StyledRadioButton_outlined"
<div
class="_inline-field_148br_40"
>
<input
disabled=""
id="theme-light"
name="theme"
type="radio"
value="light"
/>
<div>
<div />
</div>
<div
class="mx_StyledRadioButton_content"
class="_inline-field-control_148br_52"
>
Light
<div
class="_container_qnvru_18"
>
<input
class="_input_qnvru_32"
id="radix-42"
name="systemTheme"
title=""
type="checkbox"
/>
<div
class="_ui_qnvru_42"
/>
</div>
</div>
<div
class="mx_StyledRadioButton_spacer"
/>
</label>
<label
class="mx_StyledRadioButton mx_ThemeSelector_dark mx_StyledRadioButton_disabled mx_StyledRadioButton_outlined"
class="_inline-field-body_148br_46"
>
<label
class="_label_148br_67"
for="radix-42"
>
Match system theme
</label>
</div>
</div>
</form>
<form
class="_root_148br_24 mx_ThemeChoicePanel_ThemeSelectors"
>
<div
class="_inline-field_148br_40 mx_ThemeChoicePanel_themeSelector mx_ThemeChoicePanel_themeSelector_enabled cpd-theme-light"
>
<input
disabled=""
id="theme-dark"
name="theme"
type="radio"
value="dark"
/>
<div>
<div />
</div>
<div
class="mx_StyledRadioButton_content"
class="_inline-field-control_148br_52"
>
Dark
<div
class="_container_1vw5h_18"
>
<input
checked=""
class="_input_1vw5h_26"
id="radix-43"
name="themeSelector"
title=""
type="radio"
value="light"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="mx_StyledRadioButton_spacer"
/>
</label>
class="_inline-field-body_148br_46"
>
<label
class="_label_148br_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-43"
>
Light
</label>
</div>
</div>
<div
class="_inline-field_148br_40 mx_ThemeChoicePanel_themeSelector cpd-theme-dark"
>
<div
class="_inline-field-control_148br_52"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
id="radix-44"
name="themeSelector"
title=""
type="radio"
value="dark"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_148br_46"
>
<label
class="_label_148br_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-44"
>
Dark
</label>
</div>
</div>
<div
class="_inline-field_148br_40 mx_ThemeChoicePanel_themeSelector cpd-theme-light"
>
<div
class="_inline-field-control_148br_52"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
id="radix-45"
name="themeSelector"
title=""
type="radio"
value="light-high-contrast"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_148br_46"
>
<label
class="_label_148br_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-45"
>
High contrast
</label>
</div>
</div>
<div
class="_inline-field_148br_40 mx_ThemeChoicePanel_themeSelector cpd-theme-dark"
>
<div
class="_inline-field-control_148br_52"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
id="radix-46"
name="themeSelector"
title=""
type="radio"
value="custom-Alice theme"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_148br_46"
>
<label
class="_label_148br_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-46"
>
Alice theme
</label>
</div>
</div>
</form>
<div
class="mx_ThemeChoicePanel_CustomTheme"
>
<form
class="_container_zfn7i_17 mx_ThemeChoicePanel_CustomTheme_EditInPlace"
id=":r7:"
>
<div
class="_label_zfn7i_21"
id=":r8:"
>
Add custom theme
</div>
<div
class="_controls_zfn7i_27"
>
<input
aria-invalid="false"
aria-labelledby=":r8:"
class="_control_9gon8_18 _control_zfn7i_27"
value=""
/>
<div
class="_button-group_zfn7i_32"
>
<button
aria-controls=":r7:"
aria-label="Add custom theme"
class="_button_zfn7i_32 _primary-button_zfn7i_51"
type="submit"
>
<svg
class="cpd-icon"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.55 17.575c-.133 0-.258-.02-.375-.063a.878.878 0 0 1-.325-.212L4.55 13c-.183-.183-.27-.42-.263-.713.009-.291.105-.529.288-.712a.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275L9.55 15.15l8.475-8.475c.183-.183.42-.275.712-.275s.53.092.713.275c.183.183.275.42.275.712s-.092.53-.275.713l-9.2 9.2c-.1.1-.208.17-.325.212a1.106 1.106 0 0 1-.375.063Z"
/>
</svg>
</button>
<button
aria-controls=":r7:"
class="_button_zfn7i_32"
role="button"
type="button"
>
<svg
class="cpd-icon"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414Z"
/>
</svg>
</button>
</div>
</div>
<span
class="_caption-line_zfn7i_92 _caption-text_zfn7i_130 _caption-text-help_zfn7i_147"
>
Enter the URL of a custom theme you want to apply.
</span>
</form>
<ul
class="mx_ThemeChoicePanel_CustomThemeList"
>
<li
aria-label="Alice theme"
class="mx_ThemeChoicePanel_CustomThemeList_theme"
>
<span
class="mx_ThemeChoicePanel_CustomThemeList_name"
>
Alice theme
</span>
<button
aria-label="Delete"
class="_icon-button_rijzz_17 _destructive_rijzz_78"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<div />
</div>
</button>
</li>
</ul>
</div>
</div>
<div
class="_separator_144s5_17"
data-kind="primary"
data-orientation="horizontal"
role="separator"
/>
</div>
</DocumentFragment>
`;
exports[`<ThemeChoicePanel /> custom theme should render the custom theme section 1`] = `
<DocumentFragment>
<div
class="mx_SettingsSubsection mx_SettingsSubsection_newUi"
data-testid="themePanel"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h3 mx_SettingsSubsectionHeading_heading"
>
Theme
</h3>
</div>
<div
class="mx_SettingsSubsection_content mx_SettingsSubsection_content_newUi"
>
<form
class="_root_148br_24"
>
<div
class="_inline-field_148br_40"
>
<div
class="_inline-field-control_148br_52"
>
<div
class="_container_qnvru_18"
>
<input
class="_input_qnvru_32"
id="radix-32"
name="systemTheme"
title=""
type="checkbox"
/>
<div
class="_ui_qnvru_42"
/>
</div>
</div>
<div
class="_inline-field-body_148br_46"
>
<label
class="_label_148br_67"
for="radix-32"
>
Match system theme
</label>
</div>
</div>
</form>
<form
class="_root_148br_24 mx_ThemeChoicePanel_ThemeSelectors"
>
<div
class="_inline-field_148br_40 mx_ThemeChoicePanel_themeSelector mx_ThemeChoicePanel_themeSelector_enabled cpd-theme-light"
>
<div
class="_inline-field-control_148br_52"
>
<div
class="_container_1vw5h_18"
>
<input
checked=""
class="_input_1vw5h_26"
id="radix-33"
name="themeSelector"
title=""
type="radio"
value="light"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_148br_46"
>
<label
class="_label_148br_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-33"
>
Light
</label>
</div>
</div>
<div
class="_inline-field_148br_40 mx_ThemeChoicePanel_themeSelector cpd-theme-dark"
>
<div
class="_inline-field-control_148br_52"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
id="radix-34"
name="themeSelector"
title=""
type="radio"
value="dark"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_148br_46"
>
<label
class="_label_148br_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-34"
>
Dark
</label>
</div>
</div>
<div
class="_inline-field_148br_40 mx_ThemeChoicePanel_themeSelector cpd-theme-light"
>
<div
class="_inline-field-control_148br_52"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
id="radix-35"
name="themeSelector"
title=""
type="radio"
value="light-high-contrast"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_148br_46"
>
<label
class="_label_148br_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-35"
>
High contrast
</label>
</div>
</div>
<div
class="_inline-field_148br_40 mx_ThemeChoicePanel_themeSelector cpd-theme-dark"
>
<div
class="_inline-field-control_148br_52"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
id="radix-36"
name="themeSelector"
title=""
type="radio"
value="custom-Alice theme"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_148br_46"
>
<label
class="_label_148br_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-36"
>
Alice theme
</label>
</div>
</div>
</form>
<div
class="mx_ThemeChoicePanel_CustomTheme"
>
<form
class="_container_zfn7i_17 mx_ThemeChoicePanel_CustomTheme_EditInPlace"
id=":r1:"
>
<div
class="_label_zfn7i_21"
id=":r2:"
>
Add custom theme
</div>
<div
class="_controls_zfn7i_27"
>
<input
aria-invalid="false"
aria-labelledby=":r2:"
class="_control_9gon8_18 _control_zfn7i_27"
value=""
/>
<div
class="_button-group_zfn7i_32"
>
<button
aria-controls=":r1:"
aria-label="Add custom theme"
class="_button_zfn7i_32 _primary-button_zfn7i_51"
type="submit"
>
<svg
class="cpd-icon"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.55 17.575c-.133 0-.258-.02-.375-.063a.878.878 0 0 1-.325-.212L4.55 13c-.183-.183-.27-.42-.263-.713.009-.291.105-.529.288-.712a.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275L9.55 15.15l8.475-8.475c.183-.183.42-.275.712-.275s.53.092.713.275c.183.183.275.42.275.712s-.092.53-.275.713l-9.2 9.2c-.1.1-.208.17-.325.212a1.106 1.106 0 0 1-.375.063Z"
/>
</svg>
</button>
<button
aria-controls=":r1:"
class="_button_zfn7i_32"
role="button"
type="button"
>
<svg
class="cpd-icon"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414Z"
/>
</svg>
</button>
</div>
</div>
<span
class="_caption-line_zfn7i_92 _caption-text_zfn7i_130 _caption-text-help_zfn7i_147"
>
Enter the URL of a custom theme you want to apply.
</span>
</form>
<ul
class="mx_ThemeChoicePanel_CustomThemeList"
>
<li
aria-label="Alice theme"
class="mx_ThemeChoicePanel_CustomThemeList_theme"
>
<span
class="mx_ThemeChoicePanel_CustomThemeList_name"
>
Alice theme
</span>
<button
aria-label="Delete"
class="_icon-button_rijzz_17 _destructive_rijzz_78"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<div />
</div>
</button>
</li>
</ul>
</div>
</div>
<div
class="_separator_144s5_17"
data-kind="primary"
data-orientation="horizontal"
role="separator"
/>
</div>
</DocumentFragment>
`;
exports[`<ThemeChoicePanel /> renders the theme choice UI 1`] = `
<DocumentFragment>
<div
class="mx_SettingsSubsection mx_SettingsSubsection_newUi"
data-testid="themePanel"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h3 mx_SettingsSubsectionHeading_heading"
>
Theme
</h3>
</div>
<div
class="mx_SettingsSubsection_content mx_SettingsSubsection_content_newUi"
>
<form
class="_root_148br_24"
>
<div
class="_inline-field_148br_40"
>
<div
class="_inline-field-control_148br_52"
>
<div
class="_container_qnvru_18"
>
<input
class="_input_qnvru_32"
id="radix-0"
name="systemTheme"
title=""
type="checkbox"
/>
<div
class="_ui_qnvru_42"
/>
</div>
</div>
<div
class="_inline-field-body_148br_46"
>
<label
class="_label_148br_67"
for="radix-0"
>
Match system theme
</label>
</div>
</div>
</form>
<form
class="_root_148br_24 mx_ThemeChoicePanel_ThemeSelectors"
>
<div
class="_inline-field_148br_40 mx_ThemeChoicePanel_themeSelector mx_ThemeChoicePanel_themeSelector_enabled cpd-theme-light"
>
<div
class="_inline-field-control_148br_52"
>
<div
class="_container_1vw5h_18"
>
<input
checked=""
class="_input_1vw5h_26"
id="radix-1"
name="themeSelector"
title=""
type="radio"
value="light"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_148br_46"
>
<label
class="_label_148br_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-1"
>
Light
</label>
</div>
</div>
<div
class="_inline-field_148br_40 mx_ThemeChoicePanel_themeSelector cpd-theme-dark"
>
<div
class="_inline-field-control_148br_52"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
id="radix-2"
name="themeSelector"
title=""
type="radio"
value="dark"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_148br_46"
>
<label
class="_label_148br_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-2"
>
Dark
</label>
</div>
</div>
<div
class="_inline-field_148br_40 mx_ThemeChoicePanel_themeSelector cpd-theme-light"
>
<div
class="_inline-field-control_148br_52"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
id="radix-3"
name="themeSelector"
title=""
type="radio"
value="light-high-contrast"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_148br_46"
>
<label
class="_label_148br_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-3"
>
High contrast
</label>
</div>
</div>
</form>
</div>
<div
class="_separator_144s5_17"
data-kind="primary"
data-orientation="horizontal"
role="separator"
/>
</div>
</DocumentFragment>
`;

View file

@ -64,8 +64,13 @@ describe("PreferencesUserSettingsTab", () => {
const mockGetValue = (val: boolean) => {
const copyOfGetValueAt = SettingsStore.getValueAt;
SettingsStore.getValueAt = (level: SettingLevel, name: string, roomId?: string, isExplicit?: boolean) => {
if (name === "sendReadReceipts") return val;
SettingsStore.getValueAt = <T,>(
level: SettingLevel,
name: string,
roomId?: string,
isExplicit?: boolean,
): T => {
if (name === "sendReadReceipts") return val as T;
return copyOfGetValueAt(level, name, roomId, isExplicit);
};
};

View file

@ -16,71 +16,134 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
class="mx_SettingsSection_subSections"
>
<div
class="mx_SettingsSubsection"
data-testid="mx_ThemeChoicePanel"
class="mx_SettingsSubsection mx_SettingsSubsection_newUi"
data-testid="themePanel"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
class="mx_Heading_h3 mx_SettingsSubsectionHeading_heading"
>
Theme
</h3>
</div>
<div
class="mx_SettingsSubsection_content"
class="mx_SettingsSubsection_content mx_SettingsSubsection_content_newUi"
>
<div
class="mx_ThemeChoicePanel_themeSelectors"
data-testid="theme-choice-panel-selectors"
<form
class="_root_148br_24 mx_ThemeChoicePanel_ThemeSelectors"
>
<label
class="mx_StyledRadioButton mx_ThemeSelector_light mx_StyledRadioButton_disabled mx_StyledRadioButton_outlined"
<div
class="_inline-field_148br_40 mx_ThemeChoicePanel_themeSelector mx_ThemeChoicePanel_themeSelector_disabled cpd-theme-light"
>
<input
disabled=""
id="theme-light"
name="theme"
type="radio"
value="light"
/>
<div>
<div />
</div>
<div
class="mx_StyledRadioButton_content"
class="_inline-field-control_148br_52"
>
Light
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
disabled=""
id="radix-0"
name="themeSelector"
title=""
type="radio"
value="light"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="mx_StyledRadioButton_spacer"
/>
</label>
<label
class="mx_StyledRadioButton mx_ThemeSelector_dark mx_StyledRadioButton_disabled mx_StyledRadioButton_outlined"
class="_inline-field-body_148br_46"
>
<label
class="_label_148br_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-0"
>
Light
</label>
</div>
</div>
<div
class="_inline-field_148br_40 mx_ThemeChoicePanel_themeSelector mx_ThemeChoicePanel_themeSelector_disabled cpd-theme-dark"
>
<input
disabled=""
id="theme-dark"
name="theme"
type="radio"
value="dark"
/>
<div>
<div />
</div>
<div
class="mx_StyledRadioButton_content"
class="_inline-field-control_148br_52"
>
Dark
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
disabled=""
id="radix-1"
name="themeSelector"
title=""
type="radio"
value="dark"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="mx_StyledRadioButton_spacer"
/>
</label>
</div>
class="_inline-field-body_148br_46"
>
<label
class="_label_148br_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-1"
>
Dark
</label>
</div>
</div>
<div
class="_inline-field_148br_40 mx_ThemeChoicePanel_themeSelector mx_ThemeChoicePanel_themeSelector_disabled cpd-theme-light"
>
<div
class="_inline-field-control_148br_52"
>
<div
class="_container_1vw5h_18"
>
<input
class="_input_1vw5h_26"
disabled=""
id="radix-2"
name="themeSelector"
title=""
type="radio"
value="light-high-contrast"
/>
<div
class="_ui_1vw5h_27"
/>
</div>
</div>
<div
class="_inline-field-body_148br_46"
>
<label
class="_label_148br_67 mx_ThemeChoicePanel_themeSelector_Label"
for="radix-2"
>
High contrast
</label>
</div>
</div>
</form>
</div>
<div
class="_separator_144s5_17"
data-kind="primary"
data-orientation="horizontal"
role="separator"
/>
</div>
<div
class="mx_SettingsSubsection"

View file

@ -21,17 +21,17 @@ import { mocked } from "jest-mock";
import QuickThemeSwitcher from "../../../../src/components/views/spaces/QuickThemeSwitcher";
import { getOrderedThemes } from "../../../../src/theme";
import ThemeChoicePanel from "../../../../src/components/views/settings/ThemeChoicePanel";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../../src/settings/SettingLevel";
import dis from "../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../src/dispatcher/actions";
import { mockPlatformPeg } from "../../../test-utils/platform";
import { useTheme } from "../../../../src/hooks/useTheme";
jest.mock("../../../../src/theme");
jest.mock("../../../../src/components/views/settings/ThemeChoicePanel", () => ({
calculateThemeState: jest.fn(),
jest.mock("../../../../src/hooks/useTheme", () => ({
useTheme: jest.fn(),
}));
jest.mock("../../../../src/theme");
jest.mock("../../../../src/settings/SettingsStore", () => ({
setValue: jest.fn(),
getValue: jest.fn(),
@ -59,9 +59,10 @@ describe("<QuickThemeSwitcher />", () => {
{ id: "light", name: "Light" },
{ id: "dark", name: "Dark" },
]);
mocked(ThemeChoicePanel).calculateThemeState.mockClear().mockReturnValue({
mocked(useTheme).mockClear().mockReturnValue({
theme: "light",
useSystemTheme: false,
systemThemeActivated: false,
});
mocked(SettingsStore).setValue.mockClear().mockResolvedValue();
mocked(dis).dispatch.mockClear();
@ -85,9 +86,9 @@ describe("<QuickThemeSwitcher />", () => {
});
it("renders dropdown correctly when use system theme is truthy", () => {
mocked(ThemeChoicePanel).calculateThemeState.mockClear().mockReturnValue({
mocked(useTheme).mockClear().mockReturnValue({
theme: "light",
useSystemTheme: true,
systemThemeActivated: true,
});
renderComponent();
expect(screen.getByText("Match system")).toBeInTheDocument();