theme: add unified theme abstraction + creators and base theme options

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2022-12-31 15:06:51 +01:00
committed by Philipp Hugenroth
parent 744b365476
commit f25222d7c1
9 changed files with 471 additions and 12 deletions
@@ -0,0 +1,96 @@
/*
* Copyright 2022 The Backstage Authors
*
* 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 {
Theme as Mui4Theme,
createTheme as createV4Theme,
ThemeOptions as ThemeOptionsV4,
} from '@material-ui/core/styles';
import { PaletteOptions as PaletteOptionsV4 } from '@material-ui/core/styles/createPalette';
import { PaletteOptions as PaletteOptionsV5 } from '@mui/material/styles/createPalette';
import {
adaptV4Theme,
Theme as Mui5Theme,
createTheme as createV5Theme,
ThemeOptions as ThemeOptionsV5,
} from '@mui/material/styles';
import { transformV5ComponentThemesToV4 } from './overrides';
import { PageTheme } from '../types';
import { defaultComponentThemes } from '../v5';
import { createBaseThemeOptions } from './createBaseThemeOptions';
import { UnifiedTheme } from './types';
export class UnifiedThemeHolder implements UnifiedTheme {
#themes = new Map<string, unknown>();
constructor(v4?: Mui4Theme, v5?: Mui5Theme) {
this.#themes = new Map();
if (v4) {
this.#themes.set('v4', v4);
}
if (v5) {
this.#themes.set('v5', v5);
}
}
getTheme(version: string): unknown | undefined {
return this.#themes.get(version);
}
}
/**
* Options for creating a new {@link UnifiedTheme}.
*
* @public
*/
export interface UnifiedThemeOptions {
palette: PaletteOptionsV4 & PaletteOptionsV5;
defaultPageTheme?: string;
pageTheme?: Record<string, PageTheme>;
fontFamily?: string;
htmlFontSize?: number;
components?: ThemeOptionsV5['components'];
}
/**
* Creates a new {@link UnifiedTheme} using the provided options.
*
* @public
*/
export function createUnifiedTheme(
options: UnifiedThemeOptions,
): UnifiedThemeHolder {
const themeOptions = createBaseThemeOptions(options);
const components = { ...defaultComponentThemes, ...options.components };
const v5Theme = createV5Theme({ ...themeOptions, components });
const v4Overrides = transformV5ComponentThemesToV4(v5Theme, components);
const v4Theme = { ...createV4Theme(themeOptions), ...v4Overrides };
return new UnifiedThemeHolder(v4Theme, v5Theme);
}
/**
* Creates a new {@link UnifiedTheme} using MUI v4 theme options.
* Note that this uses `adaptV4Theme` from MUI v5, which is deprecated.
*
* @public
*/
export function createUnifiedThemeFromV4(options: ThemeOptionsV4) {
const v4Theme = createV4Theme(options);
const v5Theme = adaptV4Theme(options as any);
return new UnifiedThemeHolder(v4Theme, v5Theme);
}
@@ -24,15 +24,15 @@ import {
ThemeProvider as Mui5Provider,
} from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import { MultiThemeHolder } from './types';
import { UnifiedTheme } from './types';
interface ThemeProviderProps {
children: ReactNode;
theme: MultiThemeHolder;
theme: UnifiedTheme;
noCssBaseline?: boolean;
}
export function MultiThemeProvider(props: ThemeProviderProps) {
export function UnifiedThemeProvider(props: ThemeProviderProps) {
const { children, theme, noCssBaseline } = props;
let result = noCssBaseline ? (
@@ -44,12 +44,12 @@ export function MultiThemeProvider(props: ThemeProviderProps) {
</>
);
const v4Theme = theme.getThemeForVersion('v4');
const v4Theme = theme.getTheme('v4');
if (v4Theme) {
result = <Mui4Provider theme={v4Theme as Mui4Theme}>{result}</Mui4Provider>;
}
const v5Theme = theme.getThemeForVersion('v5');
const v5Theme = theme.getTheme('v5');
if (v5Theme) {
result = <Mui5Provider theme={v5Theme as Mui5Theme}>{result}</Mui5Provider>;
}
@@ -0,0 +1,93 @@
/*
* Copyright 2022 The Backstage Authors
*
* 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 { PageTheme, PageThemeSelector } from '../types';
import { pageTheme as defaultPageThemes } from '../pageTheme';
const DEFAULT_HTML_FONT_SIZE = 16;
const DEFAULT_FONT_FAMILY =
'"Helvetica Neue", Helvetica, Roboto, Arial, sans-serif';
const DEFAULT_PAGE_THEME = 'home';
export interface BaseThemeOptionsInput<PaletteOptions> {
palette: PaletteOptions;
defaultPageTheme?: string;
pageTheme?: Record<string, PageTheme>;
fontFamily?: string;
htmlFontSize?: number;
}
/**
* A helper for creating theme options.
*
* @public
*/
export function createBaseThemeOptions<PaletteOptions>(
options: BaseThemeOptionsInput<PaletteOptions>,
) {
const {
palette,
htmlFontSize = DEFAULT_HTML_FONT_SIZE,
fontFamily = DEFAULT_FONT_FAMILY,
defaultPageTheme = DEFAULT_PAGE_THEME,
pageTheme = defaultPageThemes,
} = options;
if (!pageTheme[defaultPageTheme]) {
throw new Error(`${defaultPageTheme} is not defined in pageTheme.`);
}
return {
palette,
typography: {
htmlFontSize,
fontFamily,
h6: {
fontWeight: 700,
fontSize: 20,
marginBottom: 2,
},
h5: {
fontWeight: 700,
fontSize: 24,
marginBottom: 4,
},
h4: {
fontWeight: 700,
fontSize: 28,
marginBottom: 6,
},
h3: {
fontSize: 32,
fontWeight: 700,
marginBottom: 6,
},
h2: {
fontSize: 40,
fontWeight: 700,
marginBottom: 8,
},
h1: {
fontSize: 54,
fontWeight: 700,
marginBottom: 10,
},
},
page: pageTheme[defaultPageTheme],
getPageTheme: ({ themeId }: PageThemeSelector) =>
pageTheme[themeId] ?? pageTheme[defaultPageTheme],
};
}
+2 -2
View File
@@ -14,5 +14,5 @@
* limitations under the License.
*/
export { MultiThemeProvider } from './MultiThemeProvider';
export type { MultiThemeHolder } from './types';
export { UnifiedThemeProvider } from './UnifiedThemeProvider';
export type { UnifiedTheme } from './types';
+2 -2
View File
@@ -16,7 +16,7 @@
import { Overrides } from '@material-ui/core/styles/overrides';
import { ComponentsProps } from '@material-ui/core/styles/props';
import { ComponentsOverrides, Theme } from '@mui/material/styles';
import { ComponentsOverrides, Theme, ThemeOptions } from '@mui/material/styles';
type V5Override = ComponentsOverrides[keyof ComponentsOverrides];
type V4Override = Overrides[keyof Overrides];
@@ -46,7 +46,7 @@ function adaptV5Override(theme: Theme, overrides: V5Override): V4Override {
// Transform v5 theme overrides into a v4 theme, by converting the callback-based overrides
export function transformV5ComponentThemesToV4(
theme: Theme,
components: Theme['components'] = {},
components: ThemeOptions['components'] = {},
): { overrides: Overrides; props: ComponentsProps } {
const overrides: Record<string, V4Override> = {};
const props: Record<string, ComponentsProps[keyof ComponentsProps]> = {};
+9 -2
View File
@@ -14,6 +14,13 @@
* limitations under the License.
*/
export interface MultiThemeHolder {
getThemeForVersion(version: string): unknown | undefined;
/**
* A container of one theme for multiple different MUI versions.
*
* Currently known keys are 'v4' and 'v5'.
*
* @public
*/
export interface UnifiedTheme {
getTheme(version: string): unknown | undefined;
}
@@ -0,0 +1,243 @@
/*
* Copyright 2022 The Backstage Authors
*
* 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 { darken, lighten, ThemeOptions } from '@mui/material/styles';
/**
* A helper for creating theme overrides.
*
* @public
*/
export const defaultComponentThemes: ThemeOptions['components'] = {
MuiCssBaseline: {
styleOverrides: theme => ({
'@global': {
html: {
height: '100%',
fontFamily: theme.typography.fontFamily,
},
body: {
height: '100%',
fontFamily: theme.typography.fontFamily,
'overscroll-behavior-y': 'none',
},
a: {
color: 'inherit',
textDecoration: 'none',
},
},
}),
},
MuiGrid: {
defaultProps: {
spacing: 2,
},
},
MuiSwitch: {
defaultProps: {
color: 'primary',
},
},
MuiTableRow: {
styleOverrides: {
// Alternating row backgrounds
root: ({ theme }) => ({
'&:nth-of-type(odd)': {
backgroundColor: theme.palette.background.default,
},
}),
// Use pointer for hoverable rows
hover: {
'&:hover': {
cursor: 'pointer',
},
},
// Alternating head backgrounds
head: ({ theme }) => ({
'&:nth-of-type(odd)': {
backgroundColor: theme.palette.background.paper,
},
}),
},
},
// Tables are more dense than default mui tables
MuiTableCell: {
styleOverrides: {
root: ({ theme }) => ({
wordBreak: 'break-word',
overflow: 'hidden',
verticalAlign: 'middle',
lineHeight: '1',
margin: 0,
padding: theme.spacing(3, 2, 3, 2.5),
borderBottom: 0,
}),
sizeSmall: ({ theme }) => ({
padding: theme.spacing(1.5, 2, 1.5, 2.5),
}),
head: {
wordBreak: 'break-word',
overflow: 'hidden',
color: 'rgb(179, 179, 179)',
fontWeight: 'normal',
lineHeight: '1',
},
},
},
MuiTabs: {
styleOverrides: {
// Tabs are smaller than default mui tab rows
root: {
minHeight: 24,
},
},
},
MuiTab: {
styleOverrides: {
// Tabs are smaller and have a hover background
root: ({ theme }) => ({
color: theme.palette.link,
minHeight: 24,
textTransform: 'initial',
letterSpacing: '0.07em',
'&:hover': {
color: darken(theme.palette.link, 0.3),
background: lighten(theme.palette.link, 0.95),
},
[theme.breakpoints.up('md')]: {
minWidth: 120,
fontSize: theme.typography.pxToRem(14),
fontWeight: 500,
},
}),
textColorPrimary: ({ theme }) => ({
color: theme.palette.link,
}),
},
},
MuiTableSortLabel: {
styleOverrides: {
// No color change on hover, just rely on the arrow showing up instead.
root: {
color: 'inherit',
'&:hover': {
color: 'inherit',
},
'&:focus': {
color: 'inherit',
},
},
// Bold font for highlighting selected column
active: {
fontWeight: 'bold',
color: 'inherit',
},
},
},
MuiListItemText: {
styleOverrides: {
dense: {
// Default dense list items to adding ellipsis for really long str...
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
},
},
MuiButton: {
styleOverrides: {
text: {
// Text buttons have less padding by default, but we want to keep the original padding
padding: undefined,
},
},
},
MuiChip: {
styleOverrides: {
root: ({ theme }) => ({
backgroundColor: '#D9D9D9',
// By default there's no margin, but it's usually wanted, so we add some trailing margin
marginRight: theme.spacing(1),
marginBottom: theme.spacing(1),
color: theme.palette.grey[900],
}),
outlined: ({ theme }) => ({
color: theme.palette.text.primary,
}),
label: ({ theme }) => ({
lineHeight: `${theme.spacing(2.5)}px`,
fontWeight: theme.typography.fontWeightMedium,
fontSize: `${theme.spacing(1.75)}px`,
}),
labelSmall: ({ theme }) => ({
fontSize: `${theme.spacing(1.5)}px`,
}),
deleteIcon: ({ theme }) => ({
color: theme.palette.grey[500],
width: `${theme.spacing(3)}px`,
height: `${theme.spacing(3)}px`,
margin: `0 ${theme.spacing(0.75)}px 0 -${theme.spacing(0.75)}px`,
}),
deleteIconSmall: ({ theme }) => ({
width: `${theme.spacing(2)}px`,
height: `${theme.spacing(2)}px`,
margin: `0 ${theme.spacing(0.5)}px 0 -${theme.spacing(0.5)}px`,
}),
},
},
MuiCard: {
styleOverrides: {
root: {
// When cards have a forced size, such as when they are arranged in a
// CSS grid, the content needs to flex such that the actions (buttons
// etc) end up at the bottom of the card instead of just below the body
// contents.
display: 'flex',
flexDirection: 'column',
},
},
},
MuiCardHeader: {
styleOverrides: {
root: {
// Reduce padding between header and content
paddingBottom: 0,
},
},
},
MuiCardContent: {
styleOverrides: {
root: {
// When cards have a forced size, such as when they are arranged in a
// CSS grid, the content needs to flex such that the actions (buttons
// etc) end up at the bottom of the card instead of just below the body
// contents.
flexGrow: 1,
'&:last-child': {
paddingBottom: undefined,
},
},
},
},
MuiCardActions: {
styleOverrides: {
root: {
// We default to putting the card actions at the end
justifyContent: 'flex-end',
},
},
},
};
+1
View File
@@ -15,3 +15,4 @@
*/
export * from './types';
export { defaultComponentThemes } from './defaultComponentThemes';
+20 -1
View File
@@ -14,7 +14,26 @@
* limitations under the License.
*/
import { BackstagePaletteAdditions, BackstageThemeAdditions } from '../types';
import { PaletteOptions } from '@mui/material/styles';
import {
BackstagePaletteAdditions,
BackstageThemeAdditions,
PageTheme,
} from '../types';
/**
* A simpler configuration for creating a new theme that just tweaks some parts
* of the backstage one.
*
* @public
*/
export type SimpleV5ThemeOptions = {
palette: PaletteOptions;
defaultPageTheme?: string;
pageTheme?: Record<string, PageTheme>;
fontFamily?: string;
htmlFontSize?: number;
};
declare module '@mui/material/styles/createPalette' {
interface Palette extends BackstagePaletteAdditions {}