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}
+
+
+
);
};