From f25222d7c1275f0abd8fca9c6a1255536e565e34 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 31 Dec 2022 15:06:51 +0100 Subject: [PATCH] theme: add unified theme abstraction + creators and base theme options Signed-off-by: Patrik Oldsberg --- packages/theme/src/compat/UnifiedTheme.tsx | 96 +++++++ ...eProvider.tsx => UnifiedThemeProvider.tsx} | 10 +- .../src/compat/createBaseThemeOptions.ts | 93 +++++++ packages/theme/src/compat/index.ts | 4 +- packages/theme/src/compat/overrides.ts | 4 +- packages/theme/src/compat/types.ts | 11 +- .../theme/src/v5/defaultComponentThemes.ts | 243 ++++++++++++++++++ packages/theme/src/v5/index.ts | 1 + packages/theme/src/v5/types.ts | 21 +- 9 files changed, 471 insertions(+), 12 deletions(-) create mode 100644 packages/theme/src/compat/UnifiedTheme.tsx rename packages/theme/src/compat/{MultiThemeProvider.tsx => UnifiedThemeProvider.tsx} (85%) create mode 100644 packages/theme/src/compat/createBaseThemeOptions.ts create mode 100644 packages/theme/src/v5/defaultComponentThemes.ts diff --git a/packages/theme/src/compat/UnifiedTheme.tsx b/packages/theme/src/compat/UnifiedTheme.tsx new file mode 100644 index 0000000000..7bd907bc84 --- /dev/null +++ b/packages/theme/src/compat/UnifiedTheme.tsx @@ -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(); + + 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; + 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); +} diff --git a/packages/theme/src/compat/MultiThemeProvider.tsx b/packages/theme/src/compat/UnifiedThemeProvider.tsx similarity index 85% rename from packages/theme/src/compat/MultiThemeProvider.tsx rename to packages/theme/src/compat/UnifiedThemeProvider.tsx index e707010dc9..19fd13eb08 100644 --- a/packages/theme/src/compat/MultiThemeProvider.tsx +++ b/packages/theme/src/compat/UnifiedThemeProvider.tsx @@ -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 = {result}; } - const v5Theme = theme.getThemeForVersion('v5'); + const v5Theme = theme.getTheme('v5'); if (v5Theme) { result = {result}; } diff --git a/packages/theme/src/compat/createBaseThemeOptions.ts b/packages/theme/src/compat/createBaseThemeOptions.ts new file mode 100644 index 0000000000..ab8a80fb94 --- /dev/null +++ b/packages/theme/src/compat/createBaseThemeOptions.ts @@ -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 { + palette: PaletteOptions; + defaultPageTheme?: string; + pageTheme?: Record; + fontFamily?: string; + htmlFontSize?: number; +} + +/** + * A helper for creating theme options. + * + * @public + */ +export function createBaseThemeOptions( + options: BaseThemeOptionsInput, +) { + 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], + }; +} diff --git a/packages/theme/src/compat/index.ts b/packages/theme/src/compat/index.ts index fe6863b8bc..836c8e6563 100644 --- a/packages/theme/src/compat/index.ts +++ b/packages/theme/src/compat/index.ts @@ -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'; diff --git a/packages/theme/src/compat/overrides.ts b/packages/theme/src/compat/overrides.ts index 0ada593740..8b6c3f94bc 100644 --- a/packages/theme/src/compat/overrides.ts +++ b/packages/theme/src/compat/overrides.ts @@ -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 = {}; const props: Record = {}; diff --git a/packages/theme/src/compat/types.ts b/packages/theme/src/compat/types.ts index dac6ef9492..f090a99b83 100644 --- a/packages/theme/src/compat/types.ts +++ b/packages/theme/src/compat/types.ts @@ -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; } diff --git a/packages/theme/src/v5/defaultComponentThemes.ts b/packages/theme/src/v5/defaultComponentThemes.ts new file mode 100644 index 0000000000..3389018f18 --- /dev/null +++ b/packages/theme/src/v5/defaultComponentThemes.ts @@ -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', + }, + }, + }, +}; diff --git a/packages/theme/src/v5/index.ts b/packages/theme/src/v5/index.ts index db229eae34..eee5d852b1 100644 --- a/packages/theme/src/v5/index.ts +++ b/packages/theme/src/v5/index.ts @@ -15,3 +15,4 @@ */ export * from './types'; +export { defaultComponentThemes } from './defaultComponentThemes'; diff --git a/packages/theme/src/v5/types.ts b/packages/theme/src/v5/types.ts index d3cf97411d..c6788bdc06 100644 --- a/packages/theme/src/v5/types.ts +++ b/packages/theme/src/v5/types.ts @@ -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; + fontFamily?: string; + htmlFontSize?: number; +}; declare module '@mui/material/styles/createPalette' { interface Palette extends BackstagePaletteAdditions {}