diff --git a/web/packages/design/src/ThemeProvider/index.tsx b/web/packages/design/src/ThemeProvider/index.tsx index da83438cb8934..1894102e9093d 100644 --- a/web/packages/design/src/ThemeProvider/index.tsx +++ b/web/packages/design/src/ThemeProvider/index.tsx @@ -36,6 +36,48 @@ 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 + ); +} + +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 = base + 'favicon-dark.png'; + } else { + favicon.href = base + '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/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..e4b8ed3c30b8f 100644 --- a/web/packages/teleport/src/services/userPreferences/userPreferences.test.ts +++ b/web/packages/teleport/src/services/userPreferences/userPreferences.test.ts @@ -16,6 +16,11 @@ * along with this program. If not, see . */ +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'; @@ -72,3 +77,105 @@ 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); +}); + +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); + 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, + })), + }); +}