From b3e551a45eb4655288094afbe145f3aaf24c30af Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 19 Jun 2024 09:56:01 -0500 Subject: [PATCH 1/3] [v15] Fix theme picker text when unspecified theme --- .../design/src/ThemeProvider/index.tsx | 27 +++++++++++- .../components/UserMenuNav/UserMenuNav.tsx | 11 +++-- .../userPreferences/userPreferences.test.ts | 43 +++++++++++++++++++ 3 files changed, 74 insertions(+), 7 deletions(-) diff --git a/web/packages/design/src/ThemeProvider/index.tsx b/web/packages/design/src/ThemeProvider/index.tsx index da83438cb8934..f154ee9e5e41a 100644 --- a/web/packages/design/src/ThemeProvider/index.tsx +++ b/web/packages/design/src/ThemeProvider/index.tsx @@ -36,6 +36,31 @@ function themePreferenceToTheme(themePreference: Theme) { return themePreference === Theme.LIGHT ? lightTheme : darkTheme; } +// because unspecified can exist but only used as a fallback and not an option, +// we need to get the current/next themes with getPrefersDark in mind. +// TODO (avatus) when we add user settings page, we can add a Theme.SYSTEM option +// and remove the checks for unspecified +export function getCurrentTheme(currentTheme: Theme): Theme { + if (currentTheme === Theme.UNSPECIFIED) { + return getPrefersDark() ? Theme.DARK : Theme.LIGHT; + } + + return currentTheme; +} + +export function getNextTheme(currentTheme: Theme): Theme { + return getCurrentTheme(currentTheme) === Theme.LIGHT + ? Theme.DARK + : Theme.LIGHT; +} + +export function getPrefersDark(): boolean { + return ( + window.matchMedia && + window.matchMedia('(prefers-color-scheme: dark)').matches + ); +} + const ThemeProvider = props => { const [themePreference, setThemePreference] = useState( storageService.getThemePreference() @@ -79,7 +104,7 @@ const ThemeProvider = props => { return ( - + {props.children} diff --git a/web/packages/teleport/src/components/UserMenuNav/UserMenuNav.tsx b/web/packages/teleport/src/components/UserMenuNav/UserMenuNav.tsx index 1fb88f4d953fa..1ff8961a1090d 100644 --- a/web/packages/teleport/src/components/UserMenuNav/UserMenuNav.tsx +++ b/web/packages/teleport/src/components/UserMenuNav/UserMenuNav.tsx @@ -22,6 +22,7 @@ import styled, { useTheme } from 'styled-components'; import { Moon, Sun, ChevronDown, Logout as LogoutIcon } from 'design/Icon'; import { Text } from 'design'; import { useRefClickOutside } from 'shared/hooks/useRefClickOutside'; +import { getCurrentTheme, getNextTheme } from 'design/ThemeProvider'; import { Theme } from 'gen-proto-ts/teleport/userpreferences/v1/theme_pb'; @@ -122,11 +123,10 @@ export function UserMenuNav({ username }: UserMenuNavProps) { const ctx = useTeleport(); const clusterId = ctx.storeUser.getClusterId(); const features = useFeatures(); + const currentTheme = getCurrentTheme(preferences.theme); + const nextTheme = getNextTheme(preferences.theme); const onThemeChange = () => { - const nextTheme = - preferences.theme === Theme.LIGHT ? Theme.DARK : Theme.LIGHT; - updatePreferences({ theme: nextTheme }); setOpen(false); }; @@ -179,10 +179,9 @@ export function UserMenuNav({ username }: UserMenuNavProps) { - {preferences.theme === Theme.DARK ? : } + {currentTheme === Theme.DARK ? : } - Switch to {preferences.theme === Theme.DARK ? 'Light' : 'Dark'}{' '} - Theme + Switch to {currentTheme === Theme.DARK ? 'Light' : 'Dark'} Theme )} diff --git a/web/packages/teleport/src/services/userPreferences/userPreferences.test.ts b/web/packages/teleport/src/services/userPreferences/userPreferences.test.ts index d32b766245060..0595e3a19ecbd 100644 --- a/web/packages/teleport/src/services/userPreferences/userPreferences.test.ts +++ b/web/packages/teleport/src/services/userPreferences/userPreferences.test.ts @@ -16,6 +16,7 @@ * along with this program. If not, see . */ +import { getCurrentTheme, getNextTheme } from 'design/ThemeProvider'; import { Theme } from 'gen-proto-ts/teleport/userpreferences/v1/theme_pb'; import { UserPreferences } from 'gen-proto-ts/teleport/userpreferences/v1/userpreferences_pb'; @@ -72,3 +73,45 @@ test('should convert the user preferences back to the old format when updating', actualUserPreferences.clusterPreferences.pinnedResources.resourceIds ); }); + +test('getCurrentTheme', () => { + mockMatchMediaWindow('dark'); + let currentTheme = getCurrentTheme(Theme.UNSPECIFIED); + expect(currentTheme).toBe(Theme.DARK); + + mockMatchMediaWindow('light'); + currentTheme = getCurrentTheme(Theme.UNSPECIFIED); + expect(currentTheme).toBe(Theme.LIGHT); + + currentTheme = getCurrentTheme(Theme.LIGHT); + expect(currentTheme).toBe(Theme.LIGHT); + + currentTheme = getCurrentTheme(Theme.DARK); + expect(currentTheme).toBe(Theme.DARK); +}); + +test('getNextTheme', () => { + mockMatchMediaWindow('dark'); + let nextTheme = getNextTheme(Theme.UNSPECIFIED); + expect(nextTheme).toBe(Theme.LIGHT); + + mockMatchMediaWindow('light'); + nextTheme = getNextTheme(Theme.UNSPECIFIED); + expect(nextTheme).toBe(Theme.DARK); + + nextTheme = getNextTheme(Theme.LIGHT); + expect(nextTheme).toBe(Theme.DARK); + + nextTheme = getNextTheme(Theme.DARK); + expect(nextTheme).toBe(Theme.LIGHT); +}); + +function mockMatchMediaWindow(prefers: 'light' | 'dark') { + return Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation(query => ({ + matches: query === `(prefers-color-scheme: ${prefers})`, + media: query, + })), + }); +} From 96bb38409a3b3d0ef541e39f6ba7c4868be7177c Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 27 Aug 2024 13:24:36 -0500 Subject: [PATCH 2/3] Add favicon theme listener (#45864) This PR changes the way we update our favicon based on system theme preference. Currently, we rely on the media attr in the link tag to obey the system preference, but some browsers don't seem to support that very well. Instead, we will now add a listener when Teleport loads to the theme and set the favicon dynamically. Note: this means the favicon will match the theme of the system/browser and NOT the theme of the page (which can be different, depending on user). The goal of the favicon theme switch is to make the favicon visible based on the browser's theme, rather than necessarily matching the page theme. This can eventually be expanded upon to be included in a theme switch when we have a user settings page that allows "system" as a theme choice --- .../design/src/ThemeProvider/index.tsx | 13 ++++ web/packages/teleport/index.html | 7 -- web/packages/teleport/src/Teleport.tsx | 28 +++++++- .../userPreferences/userPreferences.test.ts | 66 ++++++++++++++++++- 4 files changed, 104 insertions(+), 10 deletions(-) diff --git a/web/packages/design/src/ThemeProvider/index.tsx b/web/packages/design/src/ThemeProvider/index.tsx index f154ee9e5e41a..15a40b19ca42c 100644 --- a/web/packages/design/src/ThemeProvider/index.tsx +++ b/web/packages/design/src/ThemeProvider/index.tsx @@ -61,6 +61,19 @@ export function getPrefersDark(): boolean { ); } +export function updateFavicon() { + const darkModePreferred = getPrefersDark(); + const favicon = document.querySelector('link[rel="icon"]'); + + if (favicon instanceof HTMLLinkElement) { + if (darkModePreferred) { + favicon.href = '/app/favicon-dark.png'; + } else { + favicon.href = '/app/favicon-light.png'; + } + } +} + const ThemeProvider = props => { const [themePreference, setThemePreference] = useState( storageService.getThemePreference() diff --git a/web/packages/teleport/index.html b/web/packages/teleport/index.html index f6c081bbe15bc..34d6ce56782c2 100644 --- a/web/packages/teleport/index.html +++ b/web/packages/teleport/index.html @@ -12,13 +12,6 @@ href="/app/favicon-light.png" rel="icon" type="image/png" - media="(prefers-color-scheme: light)" - /> - diff --git a/web/packages/teleport/src/Teleport.tsx b/web/packages/teleport/src/Teleport.tsx index b5c15090b3d00..83158e8e70062 100644 --- a/web/packages/teleport/src/Teleport.tsx +++ b/web/packages/teleport/src/Teleport.tsx @@ -16,8 +16,8 @@ * along with this program. If not, see . */ -import React, { Suspense } from 'react'; -import ThemeProvider from 'design/ThemeProvider'; +import React, { Suspense, useEffect } from 'react'; +import ThemeProvider, { updateFavicon } from 'design/ThemeProvider'; import { Route, Router, Switch } from 'teleport/components/Router'; import { CatchError } from 'teleport/components/CatchError'; @@ -55,6 +55,30 @@ const Teleport: React.FC = props => { const { ctx, history } = props; const createPublicRoutes = props.renderPublicRoutes || publicOSSRoutes; const createPrivateRoutes = props.renderPrivateRoutes || privateOSSRoutes; + // update the favicon based on the system pref, and listen if it changes + // overtime. + // TODO(avatus) this can be expanded upon eventually to handle the entire theme + // once we have a user settings page that allows users to properly set their theme + // to respect the system prefs. We only update the favicon here because the selected theme + // of the page doesn't necessarily match the theme of the browser, which is what we + // are trying to match. + useEffect(() => { + updateFavicon(); + + const colorSchemeQueryList = window.matchMedia( + '(prefers-color-scheme: dark)' + ); + + const colorSchemeListener = () => { + updateFavicon(); + }; + + colorSchemeQueryList.addEventListener('change', colorSchemeListener); + + return () => { + colorSchemeQueryList.removeEventListener('change', colorSchemeListener); + }; + }, []); return ( diff --git a/web/packages/teleport/src/services/userPreferences/userPreferences.test.ts b/web/packages/teleport/src/services/userPreferences/userPreferences.test.ts index 0595e3a19ecbd..e4b8ed3c30b8f 100644 --- a/web/packages/teleport/src/services/userPreferences/userPreferences.test.ts +++ b/web/packages/teleport/src/services/userPreferences/userPreferences.test.ts @@ -16,7 +16,11 @@ * along with this program. If not, see . */ -import { getCurrentTheme, getNextTheme } from 'design/ThemeProvider'; +import { + getCurrentTheme, + getNextTheme, + updateFavicon, +} from 'design/ThemeProvider'; import { Theme } from 'gen-proto-ts/teleport/userpreferences/v1/theme_pb'; import { UserPreferences } from 'gen-proto-ts/teleport/userpreferences/v1/userpreferences_pb'; @@ -90,6 +94,66 @@ test('getCurrentTheme', () => { expect(currentTheme).toBe(Theme.DARK); }); +describe('updateFavicon', () => { + let originalMatchMedia: typeof window.matchMedia; + + beforeAll(() => { + originalMatchMedia = window.matchMedia; + }); + + afterAll(() => { + window.matchMedia = originalMatchMedia; + }); + + beforeEach(() => { + document.body.innerHTML = ''; + const link = document.createElement('link'); + link.rel = 'icon'; + link.href = '/initial-favicon.png'; + document.head.appendChild(link); + }); + + test('set dark favicon when dark theme is preferred', () => { + window.matchMedia = jest.fn().mockImplementation(query => ({ + matches: query === '(prefers-color-scheme: dark)', + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })); + + updateFavicon(); + + const favicon = document.querySelector( + 'link[rel="icon"]' + ) as HTMLLinkElement; + expect(favicon.href).toContain('/app/favicon-dark.png'); + }); + + test('set light favicon when light theme is preferred', () => { + window.matchMedia = jest.fn().mockImplementation(query => ({ + matches: query !== '(prefers-color-scheme: dark)', + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })); + + updateFavicon(); + + const favicon = document.querySelector( + 'link[rel="icon"]' + ) as HTMLLinkElement; + expect(favicon.href).toContain('/app/favicon-light.png'); + }); +}); + test('getNextTheme', () => { mockMatchMediaWindow('dark'); let nextTheme = getNextTheme(Theme.UNSPECIFIED); From cb41191d217ad97e9677a022d7090c0f254a63c7 Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 18 Sep 2024 14:52:56 -0500 Subject: [PATCH 3/3] Use dynamic base path for favicon images (#46719) --- web/packages/design/src/ThemeProvider/index.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/web/packages/design/src/ThemeProvider/index.tsx b/web/packages/design/src/ThemeProvider/index.tsx index 15a40b19ca42c..1894102e9093d 100644 --- a/web/packages/design/src/ThemeProvider/index.tsx +++ b/web/packages/design/src/ThemeProvider/index.tsx @@ -62,14 +62,18 @@ export function getPrefersDark(): boolean { } export function updateFavicon() { + let base = '/web/app/'; + if (import.meta.env.MODE === 'development') { + base = '/app/'; + } const darkModePreferred = getPrefersDark(); const favicon = document.querySelector('link[rel="icon"]'); if (favicon instanceof HTMLLinkElement) { if (darkModePreferred) { - favicon.href = '/app/favicon-dark.png'; + favicon.href = base + 'favicon-dark.png'; } else { - favicon.href = '/app/favicon-light.png'; + favicon.href = base + 'favicon-light.png'; } } } @@ -117,7 +121,7 @@ const ThemeProvider = props => { return ( - + {props.children}