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 e2epull/28217/head
|
@ -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();
|
||||
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
}
|
||||
}
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 7.1 KiB |
After Width: | Height: | Size: 49 KiB |
After Width: | Height: | Size: 17 KiB |
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 8.5 KiB |
After Width: | Height: | Size: 9.0 KiB |
After Width: | Height: | Size: 8.2 KiB |
After Width: | Height: | Size: 64 KiB |
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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();
|
||||
|
|