mirror of
https://github.com/vector-im/element-web.git
synced 2026-03-05 21:42:13 +01:00
Update UserMenu theme toggle to use IconButton (#32591)
* Update UserMenu theme toggle to use IconButton This lets it use the correct compound colour based on theme Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Delint Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Add tests Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Update screenshot Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
parent
c2ed88fbf0
commit
fd4695f3d5
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 14 KiB |
@ -86,14 +86,8 @@ Please see LICENSE files in the repository root for full details.
|
||||
}
|
||||
|
||||
.mx_UserMenu_contextMenu_themeButton {
|
||||
min-width: 32px;
|
||||
max-width: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
border-radius: 32px;
|
||||
background-color: $theme-button-bg-color;
|
||||
cursor: pointer;
|
||||
|
||||
/* to make alignment easier, create flexbox for the image */
|
||||
display: flex;
|
||||
@ -102,6 +96,13 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
/* For enhanced visibility under contrast control */
|
||||
outline: 1px solid transparent;
|
||||
|
||||
/* Compound overrides to match transitional designs */
|
||||
padding: var(--cpd-space-2x);
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&.mx_UserMenu_contextMenu_guestPrompts {
|
||||
|
||||
@ -250,7 +250,6 @@ $visual-bell-bg-color: #800;
|
||||
$event-timestamp-color: var(--cpd-color-text-secondary);
|
||||
$composer-shadow-color: rgba(0, 0, 0, 0.28);
|
||||
$breadcrumb-placeholder-bg-color: #272c35;
|
||||
$theme-button-bg-color: #e3e8f0;
|
||||
$resend-button-divider-color: var(--cpd-color-gray-700);
|
||||
$inlinecode-border-color: $quinary-content;
|
||||
$inlinecode-background-color: $system;
|
||||
|
||||
@ -138,8 +138,6 @@ $room-icon-unread-color: var(--cpd-color-icon-tertiary);
|
||||
|
||||
/* ******************** */
|
||||
|
||||
$theme-button-bg-color: #e3e8f0;
|
||||
|
||||
$roomtile-default-badge-bg-color: #61708b;
|
||||
$roomtile-selected-bg-color: #fff;
|
||||
|
||||
|
||||
@ -21,7 +21,6 @@ $input-darker-bg-color: $quinary-content;
|
||||
$input-darker-fg-color: $secondary-content;
|
||||
$resend-button-divider-color: $input-darker-bg-color;
|
||||
$icon-button-color: var(--cpd-color-icon-tertiary);
|
||||
$theme-button-bg-color: $quinary-content;
|
||||
/* not using a compound color here for now as we want to have the same color in
|
||||
light and dark theme. Until we have a non-symetrical token for it, let's keep
|
||||
it hardcoded to the following value */
|
||||
|
||||
@ -321,7 +321,6 @@ $visual-bell-bg-color: #faa;
|
||||
$event-timestamp-color: var(--cpd-color-text-secondary);
|
||||
$composer-shadow-color: rgba(0, 0, 0, 0.04);
|
||||
$breadcrumb-placeholder-bg-color: #e8eef5;
|
||||
$theme-button-bg-color: $quinary-content;
|
||||
$resend-button-divider-color: $input-darker-bg-color;
|
||||
$inlinecode-border-color: $quinary-content;
|
||||
$inlinecode-background-color: $system;
|
||||
|
||||
@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, createRef, type ReactNode } from "react";
|
||||
import React, { type JSX, createRef, type ReactNode, useMemo } from "react";
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
import {
|
||||
ChatSolidIcon,
|
||||
@ -18,6 +18,7 @@ import {
|
||||
NotificationsSolidIcon,
|
||||
ThemeIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { IconButton } from "@vector-im/compound-web";
|
||||
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
@ -31,8 +32,8 @@ import FeedbackDialog from "../views/dialogs/FeedbackDialog";
|
||||
import Modal from "../../Modal";
|
||||
import LogoutDialog, { shouldShowLogoutDialog } from "../views/dialogs/LogoutDialog";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import { findHighContrastTheme, getCustomTheme, isHighContrastTheme } from "../../theme";
|
||||
import { RovingAccessibleButton } from "../../accessibility/RovingTabIndex";
|
||||
import { findHighContrastTheme, isHighContrastTheme } from "../../theme";
|
||||
import { useRovingTabIndex } from "../../accessibility/RovingTabIndex";
|
||||
import AccessibleButton, { type ButtonEvent } from "../views/elements/AccessibleButton";
|
||||
import SdkConfig from "../../SdkConfig";
|
||||
import { getHomePageUrl } from "../../utils/pages";
|
||||
@ -52,6 +53,8 @@ import PosthogTrackers from "../../PosthogTrackers";
|
||||
import { type ViewHomePagePayload } from "../../dispatcher/payloads/ViewHomePagePayload";
|
||||
import { SDKContext } from "../../contexts/SDKContext";
|
||||
import { shouldShowFeedback } from "../../utils/Feedback";
|
||||
import ThemeWatcher, { ThemeWatcherEvent } from "../../settings/watchers/ThemeWatcher.ts";
|
||||
import { useTypedEventEmitterState } from "../../hooks/useEventEmitter.ts";
|
||||
|
||||
interface IProps {
|
||||
isPanelCollapsed: boolean;
|
||||
@ -62,8 +65,6 @@ type PartialDOMRect = Pick<DOMRect, "width" | "left" | "top" | "height">;
|
||||
|
||||
interface IState {
|
||||
contextMenuPosition: PartialDOMRect | null;
|
||||
isDarkTheme: boolean;
|
||||
isHighContrast: boolean;
|
||||
selectedSpace?: Room | null;
|
||||
}
|
||||
|
||||
@ -83,6 +84,51 @@ const below = (rect: PartialDOMRect): MenuProps => {
|
||||
};
|
||||
};
|
||||
|
||||
const ThemeSwitchButton = (): JSX.Element => {
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex();
|
||||
const themeWatcher = useMemo(() => new ThemeWatcher(), []);
|
||||
const [isHighContrast, isDark] = useTypedEventEmitterState(
|
||||
themeWatcher,
|
||||
ThemeWatcherEvent.Change,
|
||||
(theme: string) => [isHighContrastTheme(theme), themeWatcher.isUserOnDarkTheme()],
|
||||
);
|
||||
|
||||
const onSwitchThemeClick = (ev: ButtonEvent): void => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
PosthogTrackers.trackInteraction("WebUserMenuThemeToggleButton", ev);
|
||||
|
||||
// Disable system theme matching if the user hits this button
|
||||
SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, false);
|
||||
|
||||
let newTheme = isDark ? "light" : "dark";
|
||||
if (isHighContrast) {
|
||||
const hcTheme = findHighContrastTheme(newTheme);
|
||||
if (hcTheme) {
|
||||
newTheme = hcTheme;
|
||||
}
|
||||
}
|
||||
SettingsStore.setValue("theme", null, SettingLevel.DEVICE, newTheme); // set at same level as Appearance tab
|
||||
themeWatcher.recheck(newTheme);
|
||||
};
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
ref={ref}
|
||||
onFocus={onFocus}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
className="mx_UserMenu_contextMenu_themeButton"
|
||||
onClick={onSwitchThemeClick}
|
||||
tooltip={isDark ? _t("user_menu|switch_theme_light") : _t("user_menu|switch_theme_dark")}
|
||||
size="32px"
|
||||
kind="secondary"
|
||||
>
|
||||
<ThemeIcon />
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default class UserMenu extends React.Component<IProps, IState> {
|
||||
public static contextType = SDKContext;
|
||||
declare public context: React.ContextType<typeof SDKContext>;
|
||||
@ -97,8 +143,6 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
||||
|
||||
this.state = {
|
||||
contextMenuPosition: null,
|
||||
isDarkTheme: this.isUserOnDarkTheme(),
|
||||
isHighContrast: this.isUserOnHighContrastTheme(),
|
||||
selectedSpace: SpaceStore.instance.activeSpaceRoom,
|
||||
};
|
||||
}
|
||||
@ -111,7 +155,6 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
||||
OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate);
|
||||
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate);
|
||||
this.dispatcherRef = defaultDispatcher.register(this.onAction);
|
||||
this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged);
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
@ -122,30 +165,6 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
||||
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate);
|
||||
}
|
||||
|
||||
private isUserOnDarkTheme(): boolean {
|
||||
if (SettingsStore.getValue("use_system_theme")) {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
} else {
|
||||
const theme = SettingsStore.getValue("theme");
|
||||
if (theme.startsWith("custom-")) {
|
||||
return !!getCustomTheme(theme.substring("custom-".length)).is_dark;
|
||||
}
|
||||
return theme === "dark";
|
||||
}
|
||||
}
|
||||
|
||||
private isUserOnHighContrastTheme(): boolean {
|
||||
if (SettingsStore.getValue("use_system_theme")) {
|
||||
return window.matchMedia("(prefers-contrast: more)").matches;
|
||||
} else {
|
||||
const theme = SettingsStore.getValue("theme");
|
||||
if (theme.startsWith("custom-")) {
|
||||
return false;
|
||||
}
|
||||
return isHighContrastTheme(theme);
|
||||
}
|
||||
}
|
||||
|
||||
private onProfileUpdate = async (): Promise<void> => {
|
||||
// the store triggered an update, so force a layout update. We don't
|
||||
// have any state to store here for that to magically happen.
|
||||
@ -158,13 +177,6 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
||||
});
|
||||
};
|
||||
|
||||
private onThemeChanged = (): void => {
|
||||
this.setState({
|
||||
isDarkTheme: this.isUserOnDarkTheme(),
|
||||
isHighContrast: this.isUserOnHighContrastTheme(),
|
||||
});
|
||||
};
|
||||
|
||||
private onAction = (payload: ActionPayload): void => {
|
||||
switch (payload.action) {
|
||||
case Action.ToggleUserMenu:
|
||||
@ -200,25 +212,6 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
||||
this.setState({ contextMenuPosition: null });
|
||||
};
|
||||
|
||||
private onSwitchThemeClick = (ev: ButtonEvent): void => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
PosthogTrackers.trackInteraction("WebUserMenuThemeToggleButton", ev);
|
||||
|
||||
// Disable system theme matching if the user hits this button
|
||||
SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, false);
|
||||
|
||||
let newTheme = this.state.isDarkTheme ? "light" : "dark";
|
||||
if (this.state.isHighContrast) {
|
||||
const hcTheme = findHighContrastTheme(newTheme);
|
||||
if (hcTheme) {
|
||||
newTheme = hcTheme;
|
||||
}
|
||||
}
|
||||
SettingsStore.setValue("theme", null, SettingLevel.DEVICE, newTheme); // set at same level as Appearance tab
|
||||
};
|
||||
|
||||
private onSettingsOpen = (ev: ButtonEvent, tabId?: string, props?: Record<string, any>): void => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
@ -398,17 +391,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<RovingAccessibleButton
|
||||
className="mx_UserMenu_contextMenu_themeButton"
|
||||
onClick={this.onSwitchThemeClick}
|
||||
title={
|
||||
this.state.isDarkTheme
|
||||
? _t("user_menu|switch_theme_light")
|
||||
: _t("user_menu|switch_theme_dark")
|
||||
}
|
||||
>
|
||||
<ThemeIcon width="16px" height="16px" />
|
||||
</RovingAccessibleButton>
|
||||
<ThemeSwitchButton />
|
||||
</div>
|
||||
{topSection}
|
||||
{primaryOptionList}
|
||||
|
||||
@ -13,7 +13,7 @@ import { TypedEventEmitter } from "matrix-js-sdk/src/matrix";
|
||||
import SettingsStore from "../SettingsStore";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import { findHighContrastTheme } from "../../theme";
|
||||
import { findHighContrastTheme, getCustomTheme } from "../../theme";
|
||||
import { type ActionPayload } from "../../dispatcher/payloads";
|
||||
import { SettingLevel } from "../SettingLevel";
|
||||
|
||||
@ -125,6 +125,17 @@ export default class ThemeWatcher extends TypedEventEmitter<ThemeWatcherEvent, T
|
||||
return SettingsStore.getValue("theme");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if user is on a dark theme, if false implies user is on a light theme
|
||||
*/
|
||||
public isUserOnDarkTheme(): boolean {
|
||||
const theme = this.currentTheme;
|
||||
if (theme.startsWith("custom-")) {
|
||||
return !!getCustomTheme(theme.substring("custom-".length)).is_dark;
|
||||
}
|
||||
return theme === "dark" || theme === "dark-hc";
|
||||
}
|
||||
|
||||
private themeBasedOnSystem(): string | undefined {
|
||||
let newTheme: string | undefined;
|
||||
if (this.preferDark.matches) {
|
||||
|
||||
@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { render, screen, waitFor } from "jest-matrix-react";
|
||||
import { fireEvent, render, screen, waitFor } from "jest-matrix-react";
|
||||
import { DEVICE_CODE_SCOPE, type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { type CryptoApi } from "matrix-js-sdk/src/crypto-api";
|
||||
import { mocked } from "jest-mock";
|
||||
@ -22,6 +22,7 @@ import Modal from "../../../../src/Modal";
|
||||
import { mockOpenIdConfiguration } from "../../../test-utils/oidc";
|
||||
import { Action } from "../../../../src/dispatcher/actions";
|
||||
import { UserTab } from "../../../../src/components/views/dialogs/UserTab";
|
||||
import SettingsStore from "../../../../src/settings/SettingsStore.ts";
|
||||
|
||||
describe("<UserMenu>", () => {
|
||||
let client: MatrixClient;
|
||||
@ -151,4 +152,26 @@ describe("<UserMenu>", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should toggle theme on switcher click", async () => {
|
||||
sdkContext.client = stubClient();
|
||||
const spy = jest.spyOn(SettingsStore, "setValue");
|
||||
|
||||
const UserMenu = wrapInSdkContext(UnwrappedUserMenu, sdkContext);
|
||||
render(<UserMenu isPanelCollapsed={true} />);
|
||||
|
||||
screen.getByRole("button", { name: /User menu/i }).click();
|
||||
|
||||
const themeSwitchButton = await screen.findByRole("button", { name: "Switch to dark mode" });
|
||||
expect(themeSwitchButton).toBeInTheDocument();
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
fireEvent.click(themeSwitchButton);
|
||||
expect(spy).toHaveBeenCalledWith("use_system_theme", null, "device", false);
|
||||
expect(spy).toHaveBeenCalledWith("theme", null, "device", "dark");
|
||||
|
||||
fireEvent.click(themeSwitchButton);
|
||||
expect(spy).toHaveBeenCalledWith("use_system_theme", null, "device", false);
|
||||
expect(spy).toHaveBeenCalledWith("theme", null, "device", "light");
|
||||
});
|
||||
});
|
||||
|
||||
@ -189,4 +189,20 @@ describe("ThemeWatcher", function () {
|
||||
const themeWatcher = new ThemeWatcher();
|
||||
expect(themeWatcher.getEffectiveTheme()).toBe("dark");
|
||||
});
|
||||
|
||||
it("should identify custom dark themes as dark", () => {
|
||||
SettingsStore.getValueAt = makeGetValueAt({ use_system_theme: false, theme: "custom-darkula" });
|
||||
SettingsStore.getValue = makeGetValue({
|
||||
custom_themes: [
|
||||
{
|
||||
name: "darkula",
|
||||
is_dark: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const themeWatcher = new ThemeWatcher();
|
||||
expect(themeWatcher.getEffectiveTheme()).toBe("custom-darkula");
|
||||
expect(themeWatcher.isUserOnDarkTheme()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user