diff --git a/docs/pages/experiments/material-ui/css-variables-custom-theme.tsx b/docs/pages/experiments/material-ui/css-variables-custom-theme.tsx new file mode 100644 index 00000000000000..8139ac8db30edc --- /dev/null +++ b/docs/pages/experiments/material-ui/css-variables-custom-theme.tsx @@ -0,0 +1,194 @@ +import * as React from 'react'; +import { + Experimental_CssVarsProvider as CssVarsProvider, + useColorScheme, + experimental_extendTheme, +} from '@mui/material/styles'; +import Moon from '@mui/icons-material/DarkMode'; +import Sun from '@mui/icons-material/LightMode'; +import Button from '@mui/material/Button'; +import Chip from '@mui/material/Chip'; +import Box from '@mui/material/Box'; +import { teal, deepOrange, orange, cyan } from '@mui/material/colors'; + +const ColorSchemePicker = () => { + const { mode, setMode } = useColorScheme(); + const [mounted, setMounted] = React.useState(false); + React.useEffect(() => { + setMounted(true); + }, []); + if (!mounted) { + return null; + } + + return ( + + ); +}; + +const theme = experimental_extendTheme({ + colorSchemes: { + light: { + palette: { + primary: teal, + secondary: deepOrange, + }, + }, + dark: { + palette: { + primary: cyan, + secondary: orange, + }, + }, + }, +}); + +export default function Page() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/docs/pages/experiments/material-ui/css-variables.tsx b/docs/pages/experiments/material-ui/css-variables.tsx new file mode 100644 index 00000000000000..c91ca0b73edefb --- /dev/null +++ b/docs/pages/experiments/material-ui/css-variables.tsx @@ -0,0 +1,97 @@ +import * as React from 'react'; +import { + Experimental_CssVarsProvider as CssVarsProvider, + useColorScheme, +} from '@mui/material/styles'; +import Moon from '@mui/icons-material/DarkMode'; +import Sun from '@mui/icons-material/LightMode'; +import Button from '@mui/material/Button'; +import Box from '@mui/material/Box'; + +const ColorSchemePicker = () => { + const { mode, setMode } = useColorScheme(); + const [mounted, setMounted] = React.useState(false); + React.useEffect(() => { + setMounted(true); + }, []); + if (!mounted) { + return null; + } + + return ( + + ); +}; + +export default function Page() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/docs/scripts/buildApi.ts b/docs/scripts/buildApi.ts index 68e19209643186..0df4bda370a9d6 100644 --- a/docs/scripts/buildApi.ts +++ b/docs/scripts/buildApi.ts @@ -190,7 +190,11 @@ async function run(argv: CommandOptions) { return directories.concat(findComponents(componentDirectory)); }, [] as ReadonlyArray<{ filename: string }>) .filter((component) => { - if (component.filename.includes('ThemeProvider')) { + if ( + component.filename.includes('ThemeProvider') || + (component.filename.includes('mui-material') && + component.filename.includes('CssVarsProvider')) + ) { return false; } if (grep === null) { diff --git a/packages/mui-material/src/Button/Button.js b/packages/mui-material/src/Button/Button.js index eb7bedab2b57d0..4a8dd782d87d1f 100644 --- a/packages/mui-material/src/Button/Button.js +++ b/packages/mui-material/src/Button/Button.js @@ -79,7 +79,7 @@ const ButtonRoot = styled(ButtonBase, { ...theme.typography.button, minWidth: 64, padding: '6px 16px', - borderRadius: theme.shape.borderRadius, + borderRadius: (theme.vars || theme).shape.borderRadius, transition: theme.transitions.create( ['background-color', 'box-shadow', 'border-color', 'color'], { @@ -88,17 +88,20 @@ const ButtonRoot = styled(ButtonBase, { ), '&:hover': { textDecoration: 'none', - backgroundColor: alpha(theme.palette.text.primary, theme.palette.action.hoverOpacity), + backgroundColor: theme.vars + ? `rgba(${theme.vars.palette.text.primaryChannel} / ${theme.vars.palette.action.hoverOpacity})` + : alpha(theme.palette.text.primary, theme.palette.action.hoverOpacity), // Reset on touch devices, it doesn't add specificity '@media (hover: none)': { backgroundColor: 'transparent', }, ...(ownerState.variant === 'text' && ownerState.color !== 'inherit' && { - backgroundColor: alpha( - theme.palette[ownerState.color].main, - theme.palette.action.hoverOpacity, - ), + backgroundColor: theme.vars + ? `rgba(${theme.vars.palette[ownerState.color].mainChannel} / ${ + theme.vars.palette.action.hoverOpacity + })` + : alpha(theme.palette[ownerState.color].main, theme.palette.action.hoverOpacity), // Reset on touch devices, it doesn't add specificity '@media (hover: none)': { backgroundColor: 'transparent', @@ -106,57 +109,58 @@ const ButtonRoot = styled(ButtonBase, { }), ...(ownerState.variant === 'outlined' && ownerState.color !== 'inherit' && { - border: `1px solid ${theme.palette[ownerState.color].main}`, - backgroundColor: alpha( - theme.palette[ownerState.color].main, - theme.palette.action.hoverOpacity, - ), + border: `1px solid ${(theme.vars || theme).palette[ownerState.color].main}`, + backgroundColor: theme.vars + ? `rgba(${theme.vars.palette[ownerState.color].mainChannel} / ${ + theme.vars.palette.action.hoverOpacity + })` + : alpha(theme.palette[ownerState.color].main, theme.palette.action.hoverOpacity), // Reset on touch devices, it doesn't add specificity '@media (hover: none)': { backgroundColor: 'transparent', }, }), ...(ownerState.variant === 'contained' && { - backgroundColor: theme.palette.grey.A100, - boxShadow: theme.shadows[4], + backgroundColor: (theme.vars || theme).palette.grey.A100, + boxShadow: (theme.vars || theme).shadows[4], // Reset on touch devices, it doesn't add specificity '@media (hover: none)': { - boxShadow: theme.shadows[2], - backgroundColor: theme.palette.grey[300], + boxShadow: (theme.vars || theme).shadows[2], + backgroundColor: (theme.vars || theme).palette.grey[300], }, }), ...(ownerState.variant === 'contained' && ownerState.color !== 'inherit' && { - backgroundColor: theme.palette[ownerState.color].dark, + backgroundColor: (theme.vars || theme).palette[ownerState.color].dark, // Reset on touch devices, it doesn't add specificity '@media (hover: none)': { - backgroundColor: theme.palette[ownerState.color].main, + backgroundColor: (theme.vars || theme).palette[ownerState.color].main, }, }), }, '&:active': { ...(ownerState.variant === 'contained' && { - boxShadow: theme.shadows[8], + boxShadow: (theme.vars || theme).shadows[8], }), }, [`&.${buttonClasses.focusVisible}`]: { ...(ownerState.variant === 'contained' && { - boxShadow: theme.shadows[6], + boxShadow: (theme.vars || theme).shadows[6], }), }, [`&.${buttonClasses.disabled}`]: { - color: theme.palette.action.disabled, + color: (theme.vars || theme).palette.action.disabled, ...(ownerState.variant === 'outlined' && { - border: `1px solid ${theme.palette.action.disabledBackground}`, + border: `1px solid ${(theme.vars || theme).palette.action.disabledBackground}`, }), ...(ownerState.variant === 'outlined' && ownerState.color === 'secondary' && { - border: `1px solid ${theme.palette.action.disabled}`, + border: `1px solid ${(theme.vars || theme).palette.action.disabled}`, }), ...(ownerState.variant === 'contained' && { - color: theme.palette.action.disabled, - boxShadow: theme.shadows[0], - backgroundColor: theme.palette.action.disabledBackground, + color: (theme.vars || theme).palette.action.disabled, + boxShadow: (theme.vars || theme).shadows[0], + backgroundColor: (theme.vars || theme).palette.action.disabledBackground, }), }, ...(ownerState.variant === 'text' && { @@ -164,28 +168,31 @@ const ButtonRoot = styled(ButtonBase, { }), ...(ownerState.variant === 'text' && ownerState.color !== 'inherit' && { - color: theme.palette[ownerState.color].main, + color: (theme.vars || theme).palette[ownerState.color].main, }), ...(ownerState.variant === 'outlined' && { padding: '5px 15px', - border: `1px solid ${ - theme.palette.mode === 'light' ? 'rgba(0, 0, 0, 0.23)' : 'rgba(255, 255, 255, 0.23)' - }`, + border: '1px solid currentColor', }), ...(ownerState.variant === 'outlined' && ownerState.color !== 'inherit' && { - color: theme.palette[ownerState.color].main, - border: `1px solid ${alpha(theme.palette[ownerState.color].main, 0.5)}`, + color: (theme.vars || theme).palette[ownerState.color].main, + border: theme.vars + ? `1px solid rgba(${theme.vars.palette[ownerState.color].mainChannel} / 0.5)` + : `1px solid ${alpha(theme.palette[ownerState.color].main, 0.5)}`, }), ...(ownerState.variant === 'contained' && { - color: theme.palette.getContrastText(theme.palette.grey[300]), - backgroundColor: theme.palette.grey[300], - boxShadow: theme.shadows[2], + color: theme.vars + ? // this is safe because grey does not change between default light/dark mode + theme.vars.palette.text.primary + : theme.palette.getContrastText?.(theme.palette.grey[300]), + backgroundColor: (theme.vars || theme).palette.grey[300], + boxShadow: (theme.vars || theme).shadows[2], }), ...(ownerState.variant === 'contained' && ownerState.color !== 'inherit' && { - color: theme.palette[ownerState.color].contrastText, - backgroundColor: theme.palette[ownerState.color].main, + color: (theme.vars || theme).palette[ownerState.color].contrastText, + backgroundColor: (theme.vars || theme).palette[ownerState.color].main, }), ...(ownerState.color === 'inherit' && { color: 'inherit', diff --git a/packages/mui-material/src/styles/CssVarsProvider.d.ts b/packages/mui-material/src/styles/CssVarsProvider.d.ts new file mode 100644 index 00000000000000..158de8ddee8ecf --- /dev/null +++ b/packages/mui-material/src/styles/CssVarsProvider.d.ts @@ -0,0 +1,28 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { CreateCssVarsProviderResult } from '@mui/system'; +import { ThemeOptions, SupportedColorScheme } from './experimental_extendTheme'; +import { PaletteWithChannels } from './createPalette'; + +export interface ThemeInput extends Omit { + colorSchemes: Partial< + Record< + SupportedColorScheme, + { + palette: PaletteWithChannels; + } + > + >; +} + +type MDCreateCssVarsProviderResult = CreateCssVarsProviderResult; + +declare const useColorScheme: MDCreateCssVarsProviderResult['useColorScheme']; +declare const getInitColorSchemeScript: MDCreateCssVarsProviderResult['getInitColorSchemeScript']; + +/** + * This component is an experimental Theme Provider that generates CSS variabels out of the theme tokens. + * It should preferably be used at **the root of your component tree**. + */ +declare const Experimental_CssVarsProvider: MDCreateCssVarsProviderResult['CssVarsProvider']; + +export { useColorScheme, getInitColorSchemeScript, Experimental_CssVarsProvider }; diff --git a/packages/mui-material/src/styles/CssVarsProvider.js b/packages/mui-material/src/styles/CssVarsProvider.js new file mode 100644 index 00000000000000..b396df8c61231a --- /dev/null +++ b/packages/mui-material/src/styles/CssVarsProvider.js @@ -0,0 +1,31 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { unstable_createCssVarsProvider as createCssVarsProvider } from '@mui/system'; +import experimental_extendTheme from './experimental_extendTheme'; +import createTypography from './createTypography'; + +const defaultTheme = experimental_extendTheme(); + +const { + CssVarsProvider: Experimental_CssVarsProvider, + useColorScheme, + getInitColorSchemeScript, +} = createCssVarsProvider({ + theme: defaultTheme, + defaultColorScheme: { + light: 'light', + dark: 'dark', + }, + prefix: 'md', + resolveTheme: (theme) => { + const newTheme = { + ...theme, + typography: createTypography(theme.palette, theme.typography), + }; + + return newTheme; + }, + shouldSkipGeneratingVar: (keys) => + !!keys[0].match(/(typography|mixins|breakpoints|direction|transitions)/), +}); + +export { useColorScheme, getInitColorSchemeScript, Experimental_CssVarsProvider }; diff --git a/packages/mui-material/src/styles/CssVarsProvider.test.js b/packages/mui-material/src/styles/CssVarsProvider.test.js new file mode 100644 index 00000000000000..840c038afe19c2 --- /dev/null +++ b/packages/mui-material/src/styles/CssVarsProvider.test.js @@ -0,0 +1,319 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { createRenderer, screen } from 'test/utils'; +import { Experimental_CssVarsProvider as CssVarsProvider, useTheme } from '@mui/material/styles'; + +describe('[Material UI] CssVarsProvider', () => { + let originalMatchmedia; + const { render } = createRenderer(); + const storage = {}; + beforeEach(() => { + originalMatchmedia = window.matchMedia; + // Create mocks of localStorage getItem and setItem functions + Object.defineProperty(global, 'localStorage', { + value: { + getItem: (key) => storage[key], + setItem: (key, value) => { + storage[key] = value; + }, + }, + configurable: true, + }); + window.matchMedia = () => ({ + addListener: () => {}, + removeListener: () => {}, + }); + }); + afterEach(() => { + window.matchMedia = originalMatchmedia; + }); + + describe('All CSS vars', () => { + it('palette', () => { + const Vars = () => { + const theme = useTheme(); + return ( +
+
{JSON.stringify(theme.vars.palette.primary)}
+
+ {JSON.stringify(theme.vars.palette.secondary)} +
+
{JSON.stringify(theme.vars.palette.error)}
+
{JSON.stringify(theme.vars.palette.info)}
+
{JSON.stringify(theme.vars.palette.success)}
+
{JSON.stringify(theme.vars.palette.warning)}
+
{JSON.stringify(theme.vars.palette.text)}
+
+ {JSON.stringify(theme.vars.palette.background)} +
+
{JSON.stringify(theme.vars.palette.divider)}
+
{JSON.stringify(theme.vars.palette.action)}
+
+ ); + }; + + render( + + + , + ); + + expect(screen.getByTestId('palette-primary').textContent).to.equal( + JSON.stringify({ + main: 'var(--md-palette-primary-main)', + light: 'var(--md-palette-primary-light)', + dark: 'var(--md-palette-primary-dark)', + contrastText: 'var(--md-palette-primary-contrastText)', + mainChannel: 'var(--md-palette-primary-mainChannel)', + lightChannel: 'var(--md-palette-primary-lightChannel)', + darkChannel: 'var(--md-palette-primary-darkChannel)', + }), + ); + expect(screen.getByTestId('palette-secondary').textContent).to.equal( + JSON.stringify({ + main: 'var(--md-palette-secondary-main)', + light: 'var(--md-palette-secondary-light)', + dark: 'var(--md-palette-secondary-dark)', + contrastText: 'var(--md-palette-secondary-contrastText)', + mainChannel: 'var(--md-palette-secondary-mainChannel)', + lightChannel: 'var(--md-palette-secondary-lightChannel)', + darkChannel: 'var(--md-palette-secondary-darkChannel)', + }), + ); + expect(screen.getByTestId('palette-error').textContent).to.equal( + JSON.stringify({ + main: 'var(--md-palette-error-main)', + light: 'var(--md-palette-error-light)', + dark: 'var(--md-palette-error-dark)', + contrastText: 'var(--md-palette-error-contrastText)', + mainChannel: 'var(--md-palette-error-mainChannel)', + lightChannel: 'var(--md-palette-error-lightChannel)', + darkChannel: 'var(--md-palette-error-darkChannel)', + }), + ); + expect(screen.getByTestId('palette-warning').textContent).to.equal( + JSON.stringify({ + main: 'var(--md-palette-warning-main)', + light: 'var(--md-palette-warning-light)', + dark: 'var(--md-palette-warning-dark)', + contrastText: 'var(--md-palette-warning-contrastText)', + mainChannel: 'var(--md-palette-warning-mainChannel)', + lightChannel: 'var(--md-palette-warning-lightChannel)', + darkChannel: 'var(--md-palette-warning-darkChannel)', + }), + ); + expect(screen.getByTestId('palette-info').textContent).to.equal( + JSON.stringify({ + main: 'var(--md-palette-info-main)', + light: 'var(--md-palette-info-light)', + dark: 'var(--md-palette-info-dark)', + contrastText: 'var(--md-palette-info-contrastText)', + mainChannel: 'var(--md-palette-info-mainChannel)', + lightChannel: 'var(--md-palette-info-lightChannel)', + darkChannel: 'var(--md-palette-info-darkChannel)', + }), + ); + expect(screen.getByTestId('palette-success').textContent).to.equal( + JSON.stringify({ + main: 'var(--md-palette-success-main)', + light: 'var(--md-palette-success-light)', + dark: 'var(--md-palette-success-dark)', + contrastText: 'var(--md-palette-success-contrastText)', + mainChannel: 'var(--md-palette-success-mainChannel)', + lightChannel: 'var(--md-palette-success-lightChannel)', + darkChannel: 'var(--md-palette-success-darkChannel)', + }), + ); + + expect(screen.getByTestId('palette-text').textContent).to.equal( + JSON.stringify({ + primary: 'var(--md-palette-text-primary)', + secondary: 'var(--md-palette-text-secondary)', + disabled: 'var(--md-palette-text-disabled)', + primaryChannel: 'var(--md-palette-text-primaryChannel)', + secondaryChannel: 'var(--md-palette-text-secondaryChannel)', + disabledChannel: 'var(--md-palette-text-disabledChannel)', + icon: 'var(--md-palette-text-icon)', + }), + ); + expect(screen.getByTestId('palette-divider').textContent).to.equal( + '"var(--md-palette-divider)"', + ); + expect(screen.getByTestId('palette-background').textContent).to.equal( + JSON.stringify({ + paper: 'var(--md-palette-background-paper)', + default: 'var(--md-palette-background-default)', + }), + ); + expect(screen.getByTestId('palette-action').textContent).to.equal( + JSON.stringify({ + active: 'var(--md-palette-action-active)', + hover: 'var(--md-palette-action-hover)', + hoverOpacity: 'var(--md-palette-action-hoverOpacity)', + selected: 'var(--md-palette-action-selected)', + selectedOpacity: 'var(--md-palette-action-selectedOpacity)', + disabled: 'var(--md-palette-action-disabled)', + disabledBackground: 'var(--md-palette-action-disabledBackground)', + disabledOpacity: 'var(--md-palette-action-disabledOpacity)', + focus: 'var(--md-palette-action-focus)', + focusOpacity: 'var(--md-palette-action-focusOpacity)', + activatedOpacity: 'var(--md-palette-action-activatedOpacity)', + disabledChannel: 'var(--md-palette-action-disabledChannel)', + }), + ); + }); + + it('opacity', () => { + const Vars = () => { + const theme = useTheme(); + return ( +
+
{JSON.stringify(theme.vars.opacity)}
+
+ ); + }; + + render( + + + , + ); + + expect(screen.getByTestId('opacity').textContent).to.equal( + JSON.stringify({ + active: 'var(--md-opacity-active)', + hover: 'var(--md-opacity-hover)', + selected: 'var(--md-opacity-selected)', + disabled: 'var(--md-opacity-disabled)', + focus: 'var(--md-opacity-focus)', + }), + ); + }); + + it('shape', () => { + const Vars = () => { + const theme = useTheme(); + return ( +
+
{JSON.stringify(theme.vars.shape)}
+
+ ); + }; + + render( + + + , + ); + + expect(screen.getByTestId('shape').textContent).to.equal( + JSON.stringify({ + borderRadius: 'var(--md-shape-borderRadius)', + }), + ); + }); + }); + + describe('Typography', () => { + it('contain expected typography', function test() { + const Text = () => { + const theme = useTheme(); + return
{Object.keys(theme.typography).join(',')}
; + }; + + const { container } = render( + + + , + ); + + expect(container.firstChild?.textContent).to.equal( + 'htmlFontSize,pxToRem,fontFamily,fontSize,fontWeightLight,fontWeightRegular,fontWeightMedium,fontWeightBold,h1,h2,h3,h4,h5,h6,subtitle1,subtitle2,body1,body2,button,caption,overline', + ); + }); + }); + + describe('Spacing', () => { + it('provides spacing utility', function test() { + const Text = () => { + const theme = useTheme(); + return
{theme.spacing(2)}
; + }; + + const { container } = render( + + + , + ); + + expect(container.firstChild?.textContent).to.equal('16px'); + }); + }); + + describe('Breakpoints', () => { + it('provides breakpoint utilities', function test() { + const Text = () => { + const theme = useTheme(); + return
{theme.breakpoints.up('sm')}
; + }; + + const { container } = render( + + + , + ); + + expect(container.firstChild?.textContent).to.equal('@media (min-width:600px)'); + }); + }); + + describe('Skipped vars', () => { + it('should not contain `variants` in theme.vars', () => { + const Consumer = () => { + const theme = useTheme(); + // @ts-expect-error + return
{theme.vars.variants ? 'variants' : ''}
; + }; + + const { container } = render( + + + , + ); + + expect(container.firstChild?.textContent).not.to.equal('variants'); + }); + + it('should not contain `typography` in theme.vars', () => { + const Consumer = () => { + const theme = useTheme(); + // @ts-expect-error + return
{theme.vars.typography ? 'typography' : ''}
; + }; + + const { container } = render( + + + , + ); + + expect(container.firstChild?.textContent).not.to.equal('typography'); + }); + + it('should not contain `focus` in theme.vars', () => { + const Consumer = () => { + const theme = useTheme(); + // @ts-expect-error + return
{theme.vars.focus ? 'focus' : ''}
; + }; + + const { container } = render( + + + , + ); + + expect(container.firstChild?.textContent).not.to.equal('focus'); + }); + }); +}); diff --git a/packages/mui-material/src/styles/createPalette.d.ts b/packages/mui-material/src/styles/createPalette.d.ts index f64ec3e088c31e..95b4a45df13970 100644 --- a/packages/mui-material/src/styles/createPalette.d.ts +++ b/packages/mui-material/src/styles/createPalette.d.ts @@ -98,6 +98,32 @@ export interface Palette { augmentColor: (options: PaletteAugmentColorOptions) => PaletteColor; } +export interface Channels { + mainChannel: string; + lightChannel: string; + darkChannel: string; +} + +export interface PaletteWithChannels { + common: CommonColors; + mode: PaletteMode; + contrastThreshold: number; + tonalOffset: PaletteTonalOffset; + primary: PaletteColor & Channels; + secondary: PaletteColor & Channels; + error: PaletteColor & Channels; + warning: PaletteColor & Channels; + info: PaletteColor & Channels; + success: PaletteColor & Channels; + grey: Color; + text: TypeText & { primaryChannel: string; secondaryChannel: string; disabledChannel: string }; + divider: TypeDivider; + action: TypeAction & { disabledChannel: string }; + background: TypeBackground; + getContrastText: (background: string) => string; + augmentColor: (options: PaletteAugmentColorOptions) => PaletteColor; +} + export type PartialTypeObject = { [P in keyof TypeObject]?: Partial }; export interface PaletteOptions { diff --git a/packages/mui-material/src/styles/experimental_extendTheme.d.ts b/packages/mui-material/src/styles/experimental_extendTheme.d.ts new file mode 100644 index 00000000000000..7f0309cfe25b1f --- /dev/null +++ b/packages/mui-material/src/styles/experimental_extendTheme.d.ts @@ -0,0 +1,90 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { ThemeOptions as SystemThemeOptions, Theme as SystemTheme } from '@mui/system'; +import { OverridableStringUnion } from '@mui/types'; +import { Mixins, MixinsOptions } from './createMixins'; +import { Palette, PaletteOptions } from './createPalette'; +import { Typography, TypographyOptions } from './createTypography'; +import { Shadows } from './shadows'; +import { Transitions, TransitionsOptions } from './createTransitions'; +import { ZIndex, ZIndexOptions } from './zIndex'; +import { Components } from './components'; + +/** + * default MD color-schemes + */ +export type DefaultColorScheme = 'light' | 'dark'; + +/** + * The application can add more color-scheme by extending this interface via module augmentation + * + * Ex. + * declare module @mui/material/styles { + * interface ColorSchemeOverrides { + * foo: true; + * } + * } + * + * // SupportedColorScheme = 'light' | 'dark' | 'foo'; + */ +export interface ColorSchemeOverrides {} +export type ExtendedColorScheme = OverridableStringUnion; + +/** + * All color-schemes that the application has + */ +export type SupportedColorScheme = DefaultColorScheme | ExtendedColorScheme; + +export interface ThemeOptions extends Omit { + mixins?: MixinsOptions; + components?: Components; + // palette?: PaletteOptions; + colorSchemes?: Record; + shadows?: Shadows; + transitions?: TransitionsOptions; + typography?: TypographyOptions | ((palette: Palette) => TypographyOptions); + zIndex?: ZIndexOptions; + unstable_strictMode?: boolean; + opacity?: { + active?: number; + hover?: number; + selected?: number; + disabled?: number; + focus?: number; + }; +} + +interface BaseTheme extends SystemTheme { + mixins: Mixins; + palette: Palette; + shadows: Shadows; + transitions: Transitions; + typography: Typography; + zIndex: ZIndex; + unstable_strictMode?: boolean; + colorSchemes: Record; + opacity: { + active: number; + hover: number; + selected: number; + disabled: number; + focus: number; + }; +} + +// shut off automatic exporting for the `BaseTheme` above +export {}; + +/** + * Our [TypeScript guide on theme customization](https://mui.com/guides/typescript/#customization-of-theme) explains in detail how you would add custom properties. + */ +export interface Theme extends BaseTheme { + components?: Components; +} + +/** + * Generate a theme base on the options received. + * @param options Takes an incomplete theme object and adds the missing parts. + * @param args Deep merge the arguments with the about to be returned theme. + * @returns A complete, ready-to-use theme object. + */ +export default function experimental_extendTheme(options?: ThemeOptions, ...args: object[]): Theme; diff --git a/packages/mui-material/src/styles/experimental_extendTheme.js b/packages/mui-material/src/styles/experimental_extendTheme.js new file mode 100644 index 00000000000000..f30c36e74fe0e8 --- /dev/null +++ b/packages/mui-material/src/styles/experimental_extendTheme.js @@ -0,0 +1,73 @@ +import { deepmerge } from '@mui/utils'; +import { colorChannel } from '@mui/system'; +import createThemeWithoutVars from './createTheme'; +import createPalette from './createPalette'; + +export const defaultOpacity = { + active: 0.54, + hover: 0.04, + selected: 0.08, + disabled: 0.26, + focus: 0.12, +}; + +function createTheme(options = {}, ...args) { + const { colorSchemes: colorSchemesInput = {}, opacity: opacityInput = {}, ...input } = options; + + // eslint-disable-next-line prefer-const + let { palette: lightPalette, ...muiTheme } = createThemeWithoutVars({ + ...input, + ...(colorSchemesInput.light && { palette: colorSchemesInput.light?.palette }), + }); + const { palette: darkPalette } = createThemeWithoutVars({ + palette: { mode: 'dark', ...colorSchemesInput.dark?.palette }, + }); + + colorSchemesInput.light = { palette: lightPalette }; + colorSchemesInput.dark = { palette: darkPalette }; + + const colorSchemes = {}; + + Object.keys(colorSchemesInput).forEach((key) => { + const palette = createPalette(colorSchemesInput[key].palette); + + Object.keys(palette).forEach((color) => { + const colors = palette[color]; + + if (colors.main) { + palette[color].mainChannel = colorChannel(colors.main); + } + if (colors.light) { + palette[color].lightChannel = colorChannel(colors.light); + } + if (colors.dark) { + palette[color].darkChannel = colorChannel(colors.dark); + } + if (colors.primary) { + palette[color].primaryChannel = colorChannel(colors.primary); + } + if (colors.secondary) { + palette[color].secondaryChannel = colorChannel(colors.secondary); + } + if (colors.disabled) { + palette[color].disabledChannel = colorChannel(colors.disabled); + } + }); + + colorSchemes[key] = { palette }; + }); + + const opacity = { + ...defaultOpacity, + ...opacityInput, + }; + + muiTheme.colorSchemes = colorSchemes; + muiTheme.opacity = opacity; + + muiTheme = args.reduce((acc, argument) => deepmerge(acc, argument), muiTheme); + + return muiTheme; +} + +export default createTheme; diff --git a/packages/mui-material/src/styles/experimental_extendTheme.test.js b/packages/mui-material/src/styles/experimental_extendTheme.test.js new file mode 100644 index 00000000000000..6d05fa2ddd1436 --- /dev/null +++ b/packages/mui-material/src/styles/experimental_extendTheme.test.js @@ -0,0 +1,323 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { createRenderer } from 'test/utils'; +import Button from '@mui/material/Button'; +import { + Experimental_CssVarsProvider as CssVarsProvider, + experimental_extendTheme as createTheme, +} from '@mui/material/styles'; +import { deepOrange, green } from '@mui/material/colors'; + +describe('experimental_extendTheme', () => { + let originalMatchmedia; + const { render } = createRenderer(); + const storage = {}; + beforeEach(() => { + originalMatchmedia = window.matchMedia; + // Create mocks of localStorage getItem and setItem functions + Object.defineProperty(global, 'localStorage', { + value: { + getItem: (key) => storage[key], + setItem: (key, value) => { + storage[key] = value; + }, + }, + configurable: true, + }); + window.matchMedia = () => ({ + addListener: () => {}, + removeListener: () => {}, + }); + }); + afterEach(() => { + window.matchMedia = originalMatchmedia; + }); + + it('should have a colorSchemes', () => { + const theme = createTheme(); + expect(typeof createTheme).to.equal('function'); + expect(typeof theme.colorSchemes).to.equal('object'); + }); + + it('should have the custom color schemes', () => { + const theme = createTheme({ + colorSchemes: { + light: { + palette: { primary: { main: deepOrange[500] }, secondary: { main: green.A400 } }, + }, + }, + }); + expect(theme.colorSchemes.light.palette.primary.main).to.equal(deepOrange[500]); + expect(theme.colorSchemes.light.palette.secondary.main).to.equal(green.A400); + }); + + it('should generate color channels', () => { + const theme = createTheme(); + expect(theme.colorSchemes.dark.palette.primary.mainChannel).to.equal('144 202 249'); + expect(theme.colorSchemes.dark.palette.primary.darkChannel).to.equal('66 165 245'); + expect(theme.colorSchemes.dark.palette.primary.lightChannel).to.equal('227 242 253'); + + expect(theme.colorSchemes.light.palette.primary.mainChannel).to.equal('25 118 210'); + expect(theme.colorSchemes.light.palette.primary.darkChannel).to.equal('21 101 192'); + expect(theme.colorSchemes.light.palette.primary.lightChannel).to.equal('66 165 245'); + + expect(theme.colorSchemes.dark.palette.secondary.mainChannel).to.equal('206 147 216'); + expect(theme.colorSchemes.dark.palette.secondary.darkChannel).to.equal('171 71 188'); + expect(theme.colorSchemes.dark.palette.secondary.lightChannel).to.equal('243 229 245'); + + expect(theme.colorSchemes.light.palette.secondary.mainChannel).to.equal('156 39 176'); + expect(theme.colorSchemes.light.palette.secondary.darkChannel).to.equal('123 31 162'); + expect(theme.colorSchemes.light.palette.secondary.lightChannel).to.equal('186 104 200'); + + expect(theme.colorSchemes.dark.palette.text.primaryChannel).to.equal('255 255 255'); + expect(theme.colorSchemes.dark.palette.text.secondaryChannel).to.equal('255 255 255'); + expect(theme.colorSchemes.dark.palette.text.disabledChannel).to.equal('255 255 255'); + expect(theme.colorSchemes.dark.palette.action.disabledChannel).to.equal('255 255 255'); + + expect(theme.colorSchemes.light.palette.text.primaryChannel).to.equal('0 0 0'); + expect(theme.colorSchemes.light.palette.text.secondaryChannel).to.equal('0 0 0'); + expect(theme.colorSchemes.light.palette.text.disabledChannel).to.equal('0 0 0'); + expect(theme.colorSchemes.light.palette.action.disabledChannel).to.equal('0 0 0'); + }); + + it('should generate color channels for custom colors', () => { + const theme = createTheme({ + colorSchemes: { + light: { + palette: { primary: { main: deepOrange[500] }, secondary: { main: green.A400 } }, + }, + }, + }); + expect(theme.colorSchemes.light.palette.primary.mainChannel).to.equal('255 87 34'); + expect(theme.colorSchemes.light.palette.secondary.mainChannel).to.equal('0 230 118'); + }); + + describe('transitions', () => { + it('[`easing`]: should provide the default values', () => { + const theme = createTheme(); + expect(theme.transitions.easing.easeInOut).to.equal('cubic-bezier(0.4, 0, 0.2, 1)'); + expect(theme.transitions.easing.easeOut).to.equal('cubic-bezier(0.0, 0, 0.2, 1)'); + expect(theme.transitions.easing.easeIn).to.equal('cubic-bezier(0.4, 0, 1, 1)'); + expect(theme.transitions.easing.sharp).to.equal('cubic-bezier(0.4, 0, 0.6, 1)'); + }); + + it('[`duration`]: should provide the default values', () => { + const theme = createTheme(); + expect(theme.transitions.duration.shortest).to.equal(150); + expect(theme.transitions.duration.shorter).to.equal(200); + expect(theme.transitions.duration.short).to.equal(250); + expect(theme.transitions.duration.standard).to.equal(300); + expect(theme.transitions.duration.complex).to.equal(375); + expect(theme.transitions.duration.enteringScreen).to.equal(225); + expect(theme.transitions.duration.leavingScreen).to.equal(195); + }); + + it('[`easing`]: should provide the custom values', () => { + const theme = createTheme({ + transitions: { + easing: { + easeInOut: 'cubic-bezier(1, 1, 1, 1)', + easeOut: 'cubic-bezier(1, 1, 1, 1)', + easeIn: 'cubic-bezier(1, 1, 1, 1)', + sharp: 'cubic-bezier(1, 1, 1, 1)', + }, + }, + }); + expect(theme.transitions.easing.easeInOut).to.equal('cubic-bezier(1, 1, 1, 1)'); + expect(theme.transitions.easing.easeOut).to.equal('cubic-bezier(1, 1, 1, 1)'); + expect(theme.transitions.easing.easeIn).to.equal('cubic-bezier(1, 1, 1, 1)'); + expect(theme.transitions.easing.sharp).to.equal('cubic-bezier(1, 1, 1, 1)'); + }); + + it('[`duration`]: should provide the custom values', () => { + const theme = createTheme({ + transitions: { + duration: { + shortest: 1, + shorter: 1, + short: 1, + standard: 1, + complex: 1, + enteringScreen: 1, + leavingScreen: 1, + }, + }, + }); + expect(theme.transitions.duration.shortest).to.equal(1); + expect(theme.transitions.duration.shorter).to.equal(1); + expect(theme.transitions.duration.short).to.equal(1); + expect(theme.transitions.duration.standard).to.equal(1); + expect(theme.transitions.duration.complex).to.equal(1); + expect(theme.transitions.duration.enteringScreen).to.equal(1); + expect(theme.transitions.duration.leavingScreen).to.equal(1); + }); + + it('should allow providing a partial structure', () => { + const theme = createTheme({ transitions: { duration: { shortest: 150 } } }); + expect(theme.transitions.duration.shorter).not.to.equal(undefined); + }); + }); + + describe('opacity', () => { + it('should provide the default opacities', () => { + const theme = createTheme(); + expect(theme.opacity).to.deep.equal({ + active: 0.54, + hover: 0.04, + selected: 0.08, + disabled: 0.26, + focus: 0.12, + }); + }); + + it('should allow overriding of the default opacities', () => { + const theme = createTheme({ + opacity: { + active: 0.4, + }, + }); + expect(theme.opacity).to.deep.equal({ + active: 0.4, + hover: 0.04, + selected: 0.08, + disabled: 0.26, + focus: 0.12, + }); + }); + }); + + describe('shadows', () => { + it('should provide the default array', () => { + const theme = createTheme(); + expect(theme.shadows[2]).to.equal( + '0px 3px 1px -2px rgba(0,0,0,0.2),0px 2px 2px 0px rgba(0,0,0,0.14),0px 1px 5px 0px rgba(0,0,0,0.12)', + ); + }); + + it('should override the array as expected', () => { + const shadows = [ + 'none', + 1, + 1, + 1, + 2, + 3, + 3, + 4, + 5, + 5, + 6, + 6, + 7, + 7, + 7, + 8, + 8, + 8, + 9, + 9, + 10, + 10, + 10, + 11, + 11, + ]; + const theme = createTheme({ shadows }); + expect(theme.shadows).to.equal(shadows); + }); + }); + + describe('components', () => { + it('should have the components as expected', () => { + const components = { + MuiDialog: { + defaultProps: { + fullScreen: true, + fullWidth: false, + }, + }, + MuiButtonBase: { + defaultProps: { + disableRipple: true, + }, + }, + MuiPopover: { + defaultProps: { + container: document.createElement('div'), + }, + }, + }; + const theme = createTheme({ components }); + expect(theme.components).to.deep.equal(components); + }); + }); + + describe('styleOverrides', () => { + it('should warn when trying to override an internal state the wrong way', () => { + let theme; + + expect(() => { + theme = createTheme({ + components: { Button: { styleOverrides: { disabled: { color: 'blue' } } } }, + }); + }).not.toErrorDev(); + expect(Object.keys(theme.components.Button.styleOverrides.disabled).length).to.equal(1); + + expect(() => { + theme = createTheme({ + components: { MuiButton: { styleOverrides: { root: { color: 'blue' } } } }, + }); + }).not.toErrorDev(); + + expect(() => { + theme = createTheme({ + components: { MuiButton: { styleOverrides: { disabled: { color: 'blue' } } } }, + }); + }).toErrorDev( + 'MUI: The `MuiButton` component increases the CSS specificity of the `disabled` internal state.', + ); + expect(Object.keys(theme.components.MuiButton.styleOverrides.disabled).length).to.equal(0); + }); + }); + + it('shallow merges multiple arguments', () => { + const theme = createTheme({ foo: 'I am foo' }, { bar: 'I am bar' }); + expect(theme.foo).to.equal('I am foo'); + expect(theme.bar).to.equal('I am bar'); + }); + + it('deep merges multiple arguments', () => { + const theme = createTheme({ custom: { foo: 'I am foo' } }, { custom: { bar: 'I am bar' } }); + expect(theme.custom.foo).to.equal('I am foo'); + expect(theme.custom.bar).to.equal('I am bar'); + }); + + it('allows callbacks using theme in variants', () => { + const theme = createTheme({ + typography: { + fontFamily: 'cursive', + }, + components: { + MuiButton: { + variants: [ + { + props: {}, // match any props combination + style: ({ theme: t }) => { + return { + fontFamily: t.typography.fontFamily, + }; + }, + }, + ], + }, + }, + }); + + const { container } = render( + +