diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index dd4bf42c2c..fc2e876468 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -14,8 +14,6 @@ * limitations under the License. */ -import { CssBaseline, ThemeProvider } from '@material-ui/core'; -import { lightTheme, darkTheme } from '@backstage/theme'; import { createApp } from '@backstage/core'; import React, { FC } from 'react'; import { BrowserRouter as Router } from 'react-router-dom'; @@ -23,7 +21,6 @@ import Root from './components/Root'; import AlertDisplay from './components/AlertDisplay'; import * as plugins from './plugins'; import apis, { alertApiForwarder } from './apis'; -import { ThemeContextType, ThemeContext, useThemeType } from './ThemeContext'; const app = createApp({ apis, @@ -33,50 +30,15 @@ const app = createApp({ const AppProvider = app.getProvider(); const AppComponent = app.getRootComponent(); -const App: FC<{}> = () => { - const [theme, toggleTheme] = useThemeType( - localStorage.getItem('theme') || 'auto', - ); - - let backstageTheme = lightTheme; - switch (theme) { - case 'light': - backstageTheme = lightTheme; - break; - case 'dark': - backstageTheme = darkTheme; - break; - default: - if (!window.matchMedia) { - backstageTheme = lightTheme; - break; - } - backstageTheme = window.matchMedia('(prefers-color-scheme: dark)').matches - ? darkTheme - : lightTheme; - break; - } - - const themeContext: ThemeContextType = { - theme, - toggleTheme, - }; - return ( - - - - - - - - - - - - - - - ); -}; +const App: FC<{}> = () => ( + + + + + + + + +); export default App; diff --git a/packages/app/src/ThemeContext.tsx b/packages/app/src/ThemeContext.tsx deleted file mode 100644 index 3d5a04f22a..0000000000 --- a/packages/app/src/ThemeContext.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * 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, { useState, useEffect } from 'react'; -export type ThemeContextType = { - theme: string; - toggleTheme: () => void; -}; -export const ThemeContext = React.createContext({ - theme: 'light', - toggleTheme: () => {}, -}); - -export function useThemeType(themeId: string): [string, () => void] { - const [theme, setTheme] = useState(themeId); - useEffect(() => { - if (!window.matchMedia) { - return () => {}; - } - const mql = window.matchMedia('(prefers-color-scheme: dark)'); - const darkListener = (event: MediaQueryListEvent) => { - if (localStorage.getItem('theme') === 'auto') { - if (event.matches) { - setTheme('dark'); - } else { - setTheme('light'); - } - setTheme('auto'); - } - }; - mql.addListener(darkListener); - return () => { - mql.removeListener(darkListener); - }; - }); - function toggleTheme() { - if (theme === 'light') { - setTheme('dark'); - localStorage.setItem('theme', 'dark'); - } else if (theme === 'dark') { - if (!window.matchMedia) { - setTheme('light'); - localStorage.setItem('theme', 'light'); - setTheme('light'); - return; - } - setTheme('auto'); - localStorage.setItem('theme', 'auto'); - setTheme('auto'); - } else { - setTheme('light'); - localStorage.setItem('theme', 'light'); - } - } - return [theme, toggleTheme]; -} diff --git a/packages/app/src/components/Root/ToggleThemeSidebarItem.tsx b/packages/app/src/components/Root/ToggleThemeSidebarItem.tsx index 6c0cd09244..a17e8f7019 100644 --- a/packages/app/src/components/Root/ToggleThemeSidebarItem.tsx +++ b/packages/app/src/components/Root/ToggleThemeSidebarItem.tsx @@ -15,42 +15,47 @@ */ import React, { FC } from 'react'; -import { SidebarItem } from '@backstage/core'; -import { ThemeContext } from '../../ThemeContext'; +import { useObservable } from 'react-use'; +import { SidebarItem, useApi, appThemeApiRef } from '@backstage/core'; import WbSunnyIcon from '@material-ui/icons/WbSunny'; import Brightness2Icon from '@material-ui/icons/Brightness2'; import ToggleOnIcon from '@material-ui/icons/ToggleOn'; -const ToggleThemeSidebarItem: FC<{}> = () => { - return ( - - {themeContext => { - let text = 'Auto'; - let icon = ToggleOnIcon; - switch (themeContext.theme) { - case 'dark': - text = 'Dark mode'; - icon = Brightness2Icon; - break; - case 'light': - text = 'Light mode'; - icon = WbSunnyIcon; - break; - default: - text = 'Auto'; - icon = ToggleOnIcon; - break; - } - return ( - - ); - }} - +const ToggleThemeSidebarItem: FC<{}> = () => { + const appThemeApi = useApi(appThemeApiRef); + const themeId = useObservable( + appThemeApi.activeThemeId$(), + appThemeApi.getActiveThemeId(), ); + + let text = 'Auto'; + let icon = ToggleOnIcon; + switch (themeId) { + case 'dark': + text = 'Dark mode'; + icon = Brightness2Icon; + break; + case 'light': + text = 'Light mode'; + icon = WbSunnyIcon; + break; + default: + text = 'Auto'; + icon = ToggleOnIcon; + break; + } + + const handleToggle = () => { + if (!themeId) { + appThemeApi.setActiveThemeId('light'); + } else if (themeId === 'light') { + appThemeApi.setActiveThemeId('dark'); + } else { + appThemeApi.setActiveThemeId(undefined); + } + }; + + return ; }; export default ToggleThemeSidebarItem; diff --git a/packages/cli/templates/default-app/packages/app/src/App.tsx b/packages/cli/templates/default-app/packages/app/src/App.tsx index e8b82ceecd..c32efd2a6b 100644 --- a/packages/cli/templates/default-app/packages/app/src/App.tsx +++ b/packages/cli/templates/default-app/packages/app/src/App.tsx @@ -1,6 +1,5 @@ -import { CssBaseline, makeStyles, ThemeProvider } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core'; import { createApp } from '@backstage/core'; -import { lightTheme } from '@backstage/theme'; import React, { FC } from 'react'; import { BrowserRouter as Router } from 'react-router-dom'; import * as plugins from './plugins'; @@ -34,13 +33,9 @@ const App: FC<{}> = () => { useStyles(); return ( - - - - - - - + + + ); }; diff --git a/packages/core/src/api/apis/ApiAggregator.test.ts b/packages/core/src/api/apis/ApiAggregator.test.ts new file mode 100644 index 0000000000..a230e0cfff --- /dev/null +++ b/packages/core/src/api/apis/ApiAggregator.test.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { ApiAggregator } from './ApiAggregator'; +import { ApiRef } from './ApiRef'; +import { ApiRegistry } from './ApiRegistry'; + +describe('ApiAggregator', () => { + const apiARef = new ApiRef({ id: 'a', description: '' }); + const apiBRef = new ApiRef({ id: 'b', description: '' }); + + it('should forward implementations', () => { + const agg = new ApiAggregator( + ApiRegistry.from([ + [apiARef, 5], + [apiBRef, 10], + ]), + ); + expect(agg.get(apiARef)).toBe(5); + expect(agg.get(apiBRef)).toBe(10); + }); + + it('should return the first implementation', () => { + const agg = new ApiAggregator( + ApiRegistry.from([ + [apiARef, 1], + [apiARef, 2], + ]), + ); + expect(agg.get(apiARef)).toBe(2); + expect(agg.get(apiBRef)).toBe(undefined); + }); +}); diff --git a/packages/core/src/api/apis/ApiAggregator.ts b/packages/core/src/api/apis/ApiAggregator.ts new file mode 100644 index 0000000000..da49ee6488 --- /dev/null +++ b/packages/core/src/api/apis/ApiAggregator.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { ApiRef } from './ApiRef'; +import { ApiHolder } from './types'; + +/** + * An ApiHolder that queries multiple other holders from for + * an Api implementation, returning the first one encountered.. + */ +export class ApiAggregator implements ApiHolder { + private readonly holders: ApiHolder[]; + + constructor(...holders: ApiHolder[]) { + this.holders = holders; + } + + get(apiRef: ApiRef): T | undefined { + for (const holder of this.holders) { + const api = holder.get(apiRef); + if (api) { + return api; + } + } + return undefined; + } +} diff --git a/packages/core/src/api/apis/definitions/AppThemeApi.ts b/packages/core/src/api/apis/definitions/AppThemeApi.ts new file mode 100644 index 0000000000..738bae8a9f --- /dev/null +++ b/packages/core/src/api/apis/definitions/AppThemeApi.ts @@ -0,0 +1,77 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { ApiRef } from '../ApiRef'; +import { BackstageTheme } from '@backstage/theme'; +import { Observable } from '../../types'; + +/** + * Describes a theme provided by the app. + */ +export type AppTheme = { + /** + * ID used to remember theme selections. + */ + id: string; + + /** + * Title of the theme + */ + title: string; + + /** + * Theme variant + */ + variant: 'light' | 'dark'; + + /** + * The specialized MaterialUI theme instance. + */ + theme: BackstageTheme; +}; + +/** + * The AppThemeApi gives access to the current app theme, and allows switching + * to other options that have been registered as a part of the App. + */ +export type AppThemeApi = { + /** + * Get a list of available themes. + */ + getInstalledThemes(): AppTheme[]; + + /** + * Observe the currently selected theme. A value of undefined means no specific theme has been selected. + */ + activeThemeId$(): Observable; + + /** + * Get the current theme ID. Returns undefined if no specific theme is selected. + */ + getActiveThemeId(): string | undefined; + + /** + * Set a specific theme to use in the app, overriding the default theme selection. + * + * Clear the selection by passing in undefined. + */ + setActiveThemeId(themeId?: string): void; +}; + +export const appThemeApiRef = new ApiRef({ + id: 'core.apptheme', + description: 'API Used to configure the app theme, and enumerate options', +}); diff --git a/packages/core/src/api/apis/definitions/index.ts b/packages/core/src/api/apis/definitions/index.ts index 9e7f7534b9..2bb365b2ab 100644 --- a/packages/core/src/api/apis/definitions/index.ts +++ b/packages/core/src/api/apis/definitions/index.ts @@ -21,6 +21,7 @@ // If you think some API definition is missing, please open an Issue or send a PR! export * from './AlertApi'; +export * from './AppThemeApi'; export * from './ErrorApi'; export * from './FeatureFlagsApi'; export * from './OAuthRequestApi'; diff --git a/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.test.ts b/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.test.ts new file mode 100644 index 0000000000..0c23fe5219 --- /dev/null +++ b/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.test.ts @@ -0,0 +1,85 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { AppTheme } from '../../definitions'; +import { AppThemeSelector } from './AppThemeSelector'; + +describe('AppThemeSelector', () => { + it('should should select new themes', async () => { + const selector = new AppThemeSelector([]); + + expect(selector.getInstalledThemes()).toEqual([]); + + const subFn = jest.fn(); + selector.activeThemeId$().subscribe(subFn); + expect(selector.getActiveThemeId()).toBe(undefined); + await 'wait a tick'; + expect(subFn).toHaveBeenLastCalledWith(undefined); + + selector.setActiveThemeId('x'); + expect(subFn).toHaveBeenLastCalledWith('x'); + expect(selector.getActiveThemeId()).toBe('x'); + + selector.setActiveThemeId(undefined); + expect(subFn).toHaveBeenLastCalledWith(undefined); + expect(selector.getActiveThemeId()).toBe(undefined); + }); + + it('should return a new array of themes', () => { + const themes = new Array(); + const selector = new AppThemeSelector(themes); + + expect(selector.getInstalledThemes()).toEqual(themes); + expect(selector.getInstalledThemes()).not.toBe(themes); + }); + + it('should store theme in local storage', async () => { + expect(AppThemeSelector.createWithStorage([]).getActiveThemeId()).toBe( + undefined, + ); + localStorage.setItem('theme', 'x'); + expect(AppThemeSelector.createWithStorage([]).getActiveThemeId()).toBe('x'); + localStorage.removeItem('theme'); + expect(AppThemeSelector.createWithStorage([]).getActiveThemeId()).toBe( + undefined, + ); + + const addListenerSpy = jest.spyOn(window, 'addEventListener'); + const selector = AppThemeSelector.createWithStorage([]); + + expect(addListenerSpy).toHaveBeenCalledTimes(1); + expect(addListenerSpy).toHaveBeenCalledWith( + 'storage', + expect.any(Function), + ); + + selector.setActiveThemeId('y'); + await 'wait a tick'; + expect(localStorage.getItem('theme')).toBe('y'); + + selector.setActiveThemeId(undefined); + await 'wait a tick'; + expect(localStorage.getItem('theme')).toBe(null); + + localStorage.setItem('theme', 'z'); + expect(selector.getActiveThemeId()).toBe(undefined); + + const listener = addListenerSpy.mock.calls[0][1] as EventListener; + listener({ key: 'theme' } as StorageEvent); + + expect(selector.getActiveThemeId()).toBe('z'); + }); +}); diff --git a/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.ts b/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.ts new file mode 100644 index 0000000000..cc1146b336 --- /dev/null +++ b/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.ts @@ -0,0 +1,91 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 Observable from 'zen-observable'; +import { AppThemeApi, AppTheme } from '../../definitions'; + +const STORAGE_KEY = 'theme'; + +export class AppThemeSelector implements AppThemeApi { + static createWithStorage(themes: AppTheme[]) { + const selector = new AppThemeSelector(themes); + + if (!window.localStorage) { + return selector; + } + + const initialThemeId = + window.localStorage.getItem(STORAGE_KEY) ?? undefined; + + selector.setActiveThemeId(initialThemeId); + + selector.activeThemeId$().subscribe((themeId) => { + if (themeId) { + window.localStorage.setItem(STORAGE_KEY, themeId); + } else { + window.localStorage.removeItem(STORAGE_KEY); + } + }); + + window.addEventListener('storage', (event) => { + if (event.key === STORAGE_KEY) { + const themeId = localStorage.getItem(STORAGE_KEY) ?? undefined; + selector.setActiveThemeId(themeId); + } + }); + + return selector; + } + + private readonly themes: AppTheme[]; + + private activeThemeId: string | undefined; + + private readonly observable: Observable; + private readonly subscribers = new Set< + ZenObservable.SubscriptionObserver + >(); + + constructor(themes: AppTheme[]) { + this.themes = themes; + + this.observable = new Observable((subscriber) => { + subscriber.next(this.activeThemeId); + + this.subscribers.add(subscriber); + return () => { + this.subscribers.delete(subscriber); + }; + }); + } + + getInstalledThemes(): AppTheme[] { + return this.themes.slice(); + } + + activeThemeId$(): Observable { + return this.observable; + } + + getActiveThemeId(): string | undefined { + return this.activeThemeId; + } + + setActiveThemeId(themeId?: string): void { + this.activeThemeId = themeId; + this.subscribers.forEach((subscriber) => subscriber.next(themeId)); + } +} diff --git a/packages/core/src/api/apis/implementations/AppThemeSelector/index.ts b/packages/core/src/api/apis/implementations/AppThemeSelector/index.ts new file mode 100644 index 0000000000..cb42c0f875 --- /dev/null +++ b/packages/core/src/api/apis/implementations/AppThemeSelector/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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. + */ + +export * from './AppThemeSelector'; diff --git a/packages/core/src/api/apis/implementations/index.ts b/packages/core/src/api/apis/implementations/index.ts index ebd89b54c6..8334b068fc 100644 --- a/packages/core/src/api/apis/implementations/index.ts +++ b/packages/core/src/api/apis/implementations/index.ts @@ -18,6 +18,7 @@ // // Plugins should rely on these APIs for functionality as much as possible. +export * from './AppThemeSelector'; export * from './AlertApiForwarder'; export * from './ErrorApiForwarder'; export * from './OAuthRequestManager'; diff --git a/packages/core/src/api/app/App.tsx b/packages/core/src/api/app/App.tsx index c44454b10e..128f978139 100644 --- a/packages/core/src/api/app/App.tsx +++ b/packages/core/src/api/app/App.tsx @@ -22,6 +22,7 @@ import { BackstagePlugin } from '../plugin'; import { FeatureFlagsRegistryItem } from './FeatureFlags'; import { featureFlagsApiRef } from '../apis/definitions'; import ErrorPage from '../../layout/ErrorPage'; +import { AppThemeProvider } from './AppThemeProvider'; import { IconComponent, @@ -29,14 +30,24 @@ import { SystemIconKey, defaultSystemIcons, } from '../../icons'; -import { ApiHolder, ApiProvider, ApiRegistry } from '../apis'; +import { + ApiHolder, + ApiProvider, + ApiRegistry, + AppTheme, + AppThemeSelector, + appThemeApiRef, +} from '../apis'; import LoginPage from './LoginPage'; +import { lightTheme, darkTheme } from '@backstage/theme'; +import { ApiAggregator } from '../apis/ApiAggregator'; type FullAppOptions = { apis: ApiHolder; icons: SystemIcons; plugins: BackstagePlugin[]; components: AppComponents; + themes: AppTheme[]; }; class AppImpl implements BackstageApp { @@ -44,12 +55,14 @@ class AppImpl implements BackstageApp { private readonly icons: SystemIcons; private readonly plugins: BackstagePlugin[]; private readonly components: AppComponents; + private readonly themes: AppTheme[]; constructor(options: FullAppOptions) { this.apis = options.apis; this.icons = options.icons; this.plugins = options.plugins; this.components = options.components; + this.themes = options.themes; } getApis(): ApiHolder { @@ -129,23 +142,28 @@ class AppImpl implements BackstageApp { , ); - let rendered = ( + const rendered = ( {routes} ); - if (this.apis) { - rendered = ; - } - return () => rendered; } getProvider(): ComponentType<{}> { + const appApis = ApiRegistry.from([ + [appThemeApiRef, AppThemeSelector.createWithStorage(this.themes)], + ]); + const apis = new ApiAggregator(this.apis, appApis); + const Provider: FC<{}> = ({ children }) => ( - {children} + + + {children} + + ); return Provider; } @@ -178,8 +196,22 @@ export function createApp(options?: AppOptions) { NotFoundErrorPage: DefaultNotFoundPage, ...options?.components, }; + const themes = options?.themes ?? [ + { + id: 'light', + title: 'Light Theme', + variant: 'light', + theme: lightTheme, + }, + { + id: 'dark', + title: 'Dark Theme', + variant: 'dark', + theme: darkTheme, + }, + ]; - const app = new AppImpl({ apis, icons, plugins, components }); + const app = new AppImpl({ apis, icons, plugins, components, themes }); app.verify(); diff --git a/packages/core/src/api/app/AppThemeProvider.tsx b/packages/core/src/api/app/AppThemeProvider.tsx new file mode 100644 index 0000000000..6e68fe9ba0 --- /dev/null +++ b/packages/core/src/api/app/AppThemeProvider.tsx @@ -0,0 +1,97 @@ +/* + * Copyright 2020 Spotify AB + * + * 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, { FC, useMemo, useEffect, useState } from 'react'; +import { ThemeProvider, CssBaseline } from '@material-ui/core'; +import { useApi, appThemeApiRef, AppTheme } from '../apis'; +import { useObservable } from 'react-use'; + +// This tries to find the most accurate match, but also falls back to less +// accurate results in order to avoid errors. +function resolveTheme( + themeId: string | undefined, + shouldPreferDark: boolean, + themes: AppTheme[], +) { + if (themeId !== undefined) { + const selectedTheme = themes.find((theme) => theme.id === themeId); + if (selectedTheme) { + return selectedTheme; + } + } + + if (shouldPreferDark) { + const darkTheme = themes.find((theme) => theme.variant === 'dark'); + if (darkTheme) { + return darkTheme; + } + } + + const lightTheme = themes.find((theme) => theme.variant === 'light'); + if (lightTheme) { + return lightTheme; + } + + return themes[0]; +} + +const useShouldPreferDarkTheme = () => { + if (!window.matchMedia) { + return false; + } + + const mediaQuery = useMemo( + () => window.matchMedia('(prefers-color-scheme: dark)'), + [], + ); + const [shouldPreferDark, setPrefersDark] = useState(mediaQuery.matches); + + useEffect(() => { + const listener = (event: MediaQueryListEvent) => { + setPrefersDark(event.matches); + }; + mediaQuery.addListener(listener); + return () => { + mediaQuery.removeListener(listener); + }; + }, [mediaQuery]); + + return shouldPreferDark; +}; + +export const AppThemeProvider: FC<{}> = ({ children }) => { + const appThemeApi = useApi(appThemeApiRef); + const shouldPreferDark = useShouldPreferDarkTheme(); + const themeId = useObservable( + appThemeApi.activeThemeId$(), + appThemeApi.getActiveThemeId(), + ); + + const appTheme = resolveTheme( + themeId, + shouldPreferDark, + appThemeApi.getInstalledThemes(), + ); + if (!appTheme) { + throw new Error('App has no themes'); + } + + return ( + + {children} + + ); +}; diff --git a/packages/core/src/api/app/types.ts b/packages/core/src/api/app/types.ts index e8d597e120..75a750ce6e 100644 --- a/packages/core/src/api/app/types.ts +++ b/packages/core/src/api/app/types.ts @@ -18,6 +18,7 @@ import { ComponentType } from 'react'; import { IconComponent, SystemIconKey, SystemIcons } from '../../icons'; import { BackstagePlugin } from '../plugin'; import { ApiHolder } from '../apis'; +import { AppTheme } from '../apis/definitions'; export type AppComponents = { NotFoundErrorPage: ComponentType<{}>; @@ -45,6 +46,28 @@ export type AppOptions = { * Supply components to the app to override the default ones. */ components?: Partial; + + /** + * Themes provided as a part of the app. By default two themes are included, one + * light variant of the default backstage theme, and one dark. + * + * This is the default config: + * + * ``` + * [{ + * id: 'light', + * title: 'Light Theme', + * variant: 'light', + * theme: lightTheme, + * }, { + * id: 'dark', + * title: 'Dark Theme', + * variant: 'dark', + * theme: darkTheme, + * }] + * ``` + */ + themes?: AppTheme[]; }; export type BackstageApp = { diff --git a/packages/dev-utils/src/devApp/render.tsx b/packages/dev-utils/src/devApp/render.tsx index 3a69faa18f..90a0847b4d 100644 --- a/packages/dev-utils/src/devApp/render.tsx +++ b/packages/dev-utils/src/devApp/render.tsx @@ -18,7 +18,6 @@ import React, { FC, ComponentType } from 'react'; import ReactDOM from 'react-dom'; import { BrowserRouter } from 'react-router-dom'; import BookmarkIcon from '@material-ui/icons/Bookmark'; -import { ThemeProvider, CssBaseline } from '@material-ui/core'; import { createApp, SidebarPage, @@ -30,7 +29,6 @@ import { ApiTestRegistry, ApiHolder, } from '@backstage/core'; -import { lightTheme } from '@backstage/theme'; import * as defaultApiFactories from './apiFactories'; // TODO(rugvip): export proper plugin type from core that isn't the plugin class @@ -78,16 +76,12 @@ class DevAppBuilder { const DevApp: FC<{}> = () => { return ( - - - - - {sidebar} - - - - - + + + {sidebar} + + + ); };