diff --git a/packages/mui-joy/src/styles/CssVarsProvider.test.tsx b/packages/mui-joy/src/styles/CssVarsProvider.test.tsx index 8f131e2f95fb48..c3dda97d7bd701 100644 --- a/packages/mui-joy/src/styles/CssVarsProvider.test.tsx +++ b/packages/mui-joy/src/styles/CssVarsProvider.test.tsx @@ -5,9 +5,11 @@ import { styled, CssVarsProvider, useTheme } from '@mui/joy/styles'; import defaultTheme from './defaultTheme'; describe('[Joy] CssVarsProvider', () => { + let originalMatchmedia: typeof window.matchMedia; const render = createClientRender(); const storage: Record = {}; - before(() => { + beforeEach(() => { + originalMatchmedia = window.matchMedia; // Create mocks of localStorage getItem and setItem functions Object.defineProperty(global, 'localStorage', { value: { @@ -18,6 +20,14 @@ describe('[Joy] CssVarsProvider', () => { }, configurable: true, }); + window.matchMedia = () => + ({ + addListener: () => {}, + removeListener: () => {}, + } as unknown as MediaQueryList); + }); + afterEach(() => { + window.matchMedia = originalMatchmedia; }); describe('All CSS vars', () => { it('palette', () => { diff --git a/packages/mui-joy/src/styles/CssVarsProvider.tsx b/packages/mui-joy/src/styles/CssVarsProvider.tsx index f006cbe1023023..fbc998a0aec8ed 100644 --- a/packages/mui-joy/src/styles/CssVarsProvider.tsx +++ b/packages/mui-joy/src/styles/CssVarsProvider.tsx @@ -12,22 +12,23 @@ type ExtendedColorScheme = OverridableStringUnion; type ColorScheme = 'light'; -interface JoyThemeInput extends StaticTheme { - colorSchemes: Record; -} +type JoyThemeInput = PartialDeep> & { + colorSchemes: Record>; + typography?: Partial; +}; -type ApplicationThemeInput = { - colorSchemes: Record>; +type ApplicationThemeInput = PartialDeep> & { + colorSchemes: Record; typography?: Partial; -} & PartialDeep>; +}; const { palette, ...rest } = defaultTheme; const { CssVarsProvider, useColorScheme, getInitColorSchemeScript } = createCssVarsProvider< JoyThemeInput, ColorScheme, - ExtendedColorScheme, - ApplicationThemeInput + ApplicationThemeInput, + ExtendedColorScheme >({ theme: { colorSchemes: { @@ -36,7 +37,7 @@ const { CssVarsProvider, useColorScheme, getInitColorSchemeScript } = createCssV }, }, ...rest, - } as unknown as JoyThemeInput, // prevent error from module augmentation inside the repository + }, defaultColorScheme: 'light', prefix: 'joy', }); diff --git a/packages/mui-system/src/cssVars/createCssVarsProvider.d.ts b/packages/mui-system/src/cssVars/createCssVarsProvider.d.ts index 28531d472c4dc7..5bd5ca0f99f586 100644 --- a/packages/mui-system/src/cssVars/createCssVarsProvider.d.ts +++ b/packages/mui-system/src/cssVars/createCssVarsProvider.d.ts @@ -1,4 +1,5 @@ import * as React from 'react'; +import { Result, Mode } from './useCurrentColorScheme'; type RequiredDeep = { [K in keyof T]-?: RequiredDeep; @@ -19,61 +20,93 @@ export type BuildCssVarsTheme = ThemeInput extends { * If yes, they must provide the palette of the extended colorScheme. Otherwise `theme` is optional. */ type DecideTheme< - Theme extends { colorSchemes: Record }, + DesignSystemTheme extends { colorSchemes: Record }, DesignSystemColorScheme extends string, + ApplicationTheme extends { colorSchemes: Record }, ApplicationColorScheme extends string | never, > = [ApplicationColorScheme] extends [never] - ? { theme?: Theme } + ? { theme?: DesignSystemTheme } : { - theme: Omit & { + theme: Omit & { colorSchemes: Partial< - Record + Record< + DesignSystemColorScheme, + DesignSystemTheme['colorSchemes'][DesignSystemColorScheme] + > > & RequiredDeep< - Record + Record >; }; }; -export interface ColorSchemeContextValue { - allColorSchemes: DesignSystemColorScheme[]; - colorScheme: DesignSystemColorScheme | undefined; - setColorScheme: React.Dispatch>; +export interface ColorSchemeContextValue + extends Result { + allColorSchemes: SupportedColorScheme[]; } export default function createCssVarsProvider< DesignSystemThemeInput extends { - colorSchemes: Record; + colorSchemes: Record; }, DesignSystemColorScheme extends string, - ApplicationColorScheme extends string = never, ApplicationThemeInput extends { - colorSchemes: Record; - } = DesignSystemThemeInput, + colorSchemes: Record; + } = never, + ApplicationColorScheme extends string = never, >(options: { - theme: Omit & { - colorSchemes: Record< - DesignSystemColorScheme, - DesignSystemThemeInput['colorSchemes'][DesignSystemColorScheme] - > & - Partial< - Record< - ApplicationColorScheme, - DesignSystemThemeInput['colorSchemes'][DesignSystemColorScheme | ApplicationColorScheme] - > - >; - }; - defaultColorScheme: DesignSystemColorScheme; + theme: DesignSystemThemeInput; + defaultColorScheme: + | DesignSystemColorScheme + | { light: DesignSystemColorScheme; dark: DesignSystemColorScheme }; + /** + * Design system default mode + * @default 'light' + */ + defaultMode?: Mode; + /** + * CSS variable prefix + * @default '' + */ prefix?: string; }): { CssVarsProvider: ( props: React.PropsWithChildren< { - defaultColorScheme?: DesignSystemColorScheme | ApplicationColorScheme; - storageKey?: string; + /** + * Application default mode (overrides design system `defaultMode` if specified) + */ + defaultMode?: Mode; + /** + * Application default colorScheme (overrides design system `defaultColorScheme` if specified) + */ + defaultColorScheme?: + | DesignSystemColorScheme + | ApplicationColorScheme + | { + light: DesignSystemColorScheme | ApplicationColorScheme; + dark: DesignSystemColorScheme | ApplicationColorScheme; + }; + /** + * localStorage key used to store application `mode` + * @default 'mui-mode' + */ + modeStorageKey?: string; + /** + * DOM attribute for applying color scheme + * @default 'data-mui-color-scheme' + */ attribute?: string; + /** + * CSS variable prefix (overrides design system `prefix` if specified) + */ prefix?: string; - } & DecideTheme + } & DecideTheme< + DesignSystemThemeInput, + DesignSystemColorScheme, + ApplicationThemeInput, + ApplicationColorScheme + > >, ) => React.ReactElement; useColorScheme: () => ColorSchemeContextValue; diff --git a/packages/mui-system/src/cssVars/createCssVarsProvider.js b/packages/mui-system/src/cssVars/createCssVarsProvider.js index 12c7ac5eb12ebc..b54e809c06d74a 100644 --- a/packages/mui-system/src/cssVars/createCssVarsProvider.js +++ b/packages/mui-system/src/cssVars/createCssVarsProvider.js @@ -7,33 +7,27 @@ import cssVarsParser from './cssVarsParser'; import ThemeProvider from '../ThemeProvider'; import getInitColorSchemeScript, { DEFAULT_ATTRIBUTE, - DEFAULT_STORAGE_KEY, + DEFAULT_MODE_STORAGE_KEY, } from './getInitColorSchemeScript'; - -const resolveMode = (key, fallback, supportedColorSchemes) => { - if (typeof window === 'undefined') { - return undefined; - } - let value; - try { - value = localStorage.getItem(key) || undefined; - if (!supportedColorSchemes.includes(value)) { - value = undefined; - } - } catch (e) { - // Unsupported - } - return value || fallback; -}; +import useCurrentColorScheme from './useCurrentColorScheme'; export default function createCssVarsProvider(options) { const { theme: baseTheme = {}, + defaultMode: desisgnSystemMode = 'light', defaultColorScheme: designSystemColorScheme, prefix: designSystemPrefix = '', } = options; - if (!baseTheme.colorSchemes || !baseTheme.colorSchemes[designSystemColorScheme]) { + if ( + !baseTheme.colorSchemes || + (typeof designSystemColorScheme === 'string' && + !baseTheme.colorSchemes[designSystemColorScheme]) || + (typeof designSystemColorScheme === 'object' && + !baseTheme.colorSchemes[designSystemColorScheme?.light]) || + (typeof designSystemColorScheme === 'object' && + !baseTheme.colorSchemes[designSystemColorScheme?.dark]) + ) { console.error(`MUI: \`${designSystemColorScheme}\` does not exist in \`theme.colorSchemes\`.`); } const ColorSchemeContext = React.createContext(undefined); @@ -50,23 +44,42 @@ export default function createCssVarsProvider(options) { children, theme: themeProp = {}, prefix = designSystemPrefix, - storageKey = DEFAULT_STORAGE_KEY, + modeStorageKey = DEFAULT_MODE_STORAGE_KEY, attribute = DEFAULT_ATTRIBUTE, + defaultMode = desisgnSystemMode, defaultColorScheme = designSystemColorScheme, }) { const { colorSchemes: baseColorSchemes = {}, ...restBaseTheme } = baseTheme; const { colorSchemes: colorSchemesProp = {}, ...restThemeProp } = themeProp; let mergedTheme = deepmerge(restBaseTheme, restThemeProp); - const colorSchemes = deepmerge(baseColorSchemes, colorSchemesProp); + const colorSchemes = deepmerge(baseColorSchemes, colorSchemesProp); const allColorSchemes = Object.keys(colorSchemes); - const joinedColorSchemes = allColorSchemes.join(','); - const [colorScheme, setColorScheme] = React.useState(() => - resolveMode(storageKey, defaultColorScheme, allColorSchemes), - ); - const resolvedColorScheme = colorScheme || defaultColorScheme; + const defaultLightColorScheme = + typeof defaultColorScheme === 'string' ? defaultColorScheme : defaultColorScheme.light; + const defaultDarkColorScheme = + typeof defaultColorScheme === 'string' ? defaultColorScheme : defaultColorScheme.dark; + const { mode, setMode, lightColorScheme, darkColorScheme, colorScheme, setColorScheme } = + useCurrentColorScheme({ + supportedColorSchemes: allColorSchemes, + defaultLightColorScheme, + defaultDarkColorScheme, + modeStorageKey, + defaultMode, + }); + const resolvedColorScheme = (() => { + if (!colorScheme) { + // This scope occurs on the server + if (defaultMode === 'dark') { + return defaultDarkColorScheme; + } + // use light color scheme, if default mode is 'light' | 'auto' + return defaultLightColorScheme; + } + return colorScheme; + })(); const { css: rootCss, vars: rootVars } = cssVarsParser(mergedTheme, { prefix }); @@ -86,8 +99,17 @@ export default function createCssVarsProvider(options) { ...vars, }; } - if (key === defaultColorScheme) { - styleSheet[':root'] = deepmerge(rootCss, css); + const resolvedDefaultColorScheme = (() => { + if (typeof defaultColorScheme === 'string') { + return defaultColorScheme; + } + if (defaultMode === 'dark') { + return defaultColorScheme.dark; + } + return defaultColorScheme.light; + })(); + if (key === resolvedDefaultColorScheme) { + styleSheet[':root'] = css; } else { styleSheet[`[${attribute}="${key}"]`] = css; } @@ -96,43 +118,22 @@ export default function createCssVarsProvider(options) { React.useEffect(() => { if (colorScheme) { document.body.setAttribute(attribute, colorScheme); - localStorage.setItem(storageKey, colorScheme); } - }, [colorScheme, attribute, storageKey]); - - // local storage modified in the context of another document - React.useEffect(() => { - const handleStorage = (event) => { - const storageColorScheme = event.newValue; - if (event.key === storageKey && joinedColorSchemes.match(storageColorScheme)) { - if (storageColorScheme) { - setColorScheme(storageColorScheme); - } - } - }; - window.addEventListener('storage', handleStorage); - return () => window.removeEventListener('storage', handleStorage); - }, [setColorScheme, storageKey, joinedColorSchemes]); - - const wrappedSetColorScheme = React.useCallback( - (val) => { - if (typeof val === 'string' && !allColorSchemes.includes(val)) { - console.error(`\`${val}\` does not exist in \`theme.colorSchemes\`.`); - } else { - setColorScheme(val); - } - }, - [setColorScheme, allColorSchemes], - ); + }, [colorScheme, attribute]); return ( + {children} @@ -151,15 +152,19 @@ export default function createCssVarsProvider(options) { /** * The initial color scheme used. */ - defaultColorScheme: PropTypes.string, + defaultColorScheme: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), /** - * css variable prefix + * The initial mode used. */ - prefix: PropTypes.string, + defaultMode: PropTypes.string, /** * The key in the local storage used to store current color scheme. */ - storageKey: PropTypes.string, + modeStorageKey: PropTypes.string, + /** + * css variable prefix + */ + prefix: PropTypes.string, /** * The calculated theme object that will be passed through context. */ diff --git a/packages/mui-system/src/cssVars/createCssVarsProvider.spec.tsx b/packages/mui-system/src/cssVars/createCssVarsProvider.spec.tsx index d43dee6b8827fb..8fd5c6e769d22b 100644 --- a/packages/mui-system/src/cssVars/createCssVarsProvider.spec.tsx +++ b/packages/mui-system/src/cssVars/createCssVarsProvider.spec.tsx @@ -138,7 +138,13 @@ interface JoyColors { } interface JoyThemeInput { - colorSchemes: Record; + colorSchemes: Record; + fontSize: string; + fontFamily: string; +} + +interface ApplicationThemeInput { + colorSchemes: Record; fontSize: string; fontFamily: string; } @@ -151,6 +157,7 @@ interface JoyColorSchemeOverrides { const { CssVarsProvider } = createCssVarsProvider< JoyThemeInput, JoyColorScheme, + ApplicationThemeInput, JoyExtendedColorScheme >({ theme: { @@ -226,7 +233,13 @@ interface Joy2Colors { } interface Joy2ThemeInput { - colorSchemes: Record; + colorSchemes: Record; + fontSize: string; + fontFamily: string; +} + +interface Application2ThemeInput { + colorSchemes: Record; fontSize: string; fontFamily: string; } @@ -240,6 +253,7 @@ interface Joy2ColorSchemeOverrides { const { CssVarsProvider: CssVarsProvider2, useColorScheme } = createCssVarsProvider< Joy2ThemeInput, Joy2ColorScheme, + Application2ThemeInput, Joy2ExtendedColorScheme >({ theme: { diff --git a/packages/mui-system/src/cssVars/createCssVarsProvider.test.js b/packages/mui-system/src/cssVars/createCssVarsProvider.test.js index 1a625e07c82a2a..0fb4faeba94ab0 100644 --- a/packages/mui-system/src/cssVars/createCssVarsProvider.test.js +++ b/packages/mui-system/src/cssVars/createCssVarsProvider.test.js @@ -3,13 +3,22 @@ import { expect } from 'chai'; import { spy } from 'sinon'; import { createClientRender, screen, fireEvent } from 'test/utils'; import createCssVarsProvider from './createCssVarsProvider'; -import { DEFAULT_ATTRIBUTE, DEFAULT_STORAGE_KEY } from './getInitColorSchemeScript'; +import { DEFAULT_ATTRIBUTE, DEFAULT_MODE_STORAGE_KEY } from './getInitColorSchemeScript'; import useTheme from '../useTheme'; describe('createCssVarsProvider', () => { const render = createClientRender(); + let originalMatchmedia; let storage = {}; - before(() => { + const createMatchMedia = (matches) => () => ({ + matches, + addListener: () => {}, + removeListener: () => {}, + }); + + beforeEach(() => { + originalMatchmedia = window.matchMedia; + // Create mocks of localStorage getItem and setItem functions Object.defineProperty(global, 'localStorage', { value: { @@ -20,11 +29,13 @@ describe('createCssVarsProvider', () => { }, configurable: true, }); - }); - beforeEach(() => { // clear the localstorage storage = {}; + window.matchMedia = createMatchMedia(false); + }); + afterEach(() => { + window.matchMedia = originalMatchmedia; }); describe('[Design System] CssVarsProvider', () => { @@ -197,30 +208,34 @@ describe('createCssVarsProvider', () => { defaultColorScheme: 'light', }); const Consumer = () => { - const { colorScheme, setColorScheme } = useColorScheme(); + const { mode, setMode } = useColorScheme(); return (
-
{colorScheme}
- +
{mode}
+
); }; - it('should save colorScheme to localStorage', () => { + it('should save mode to localStorage', () => { render( , ); - expect(global.localStorage.setItem.lastCall.args).to.eql([DEFAULT_STORAGE_KEY, 'light']); + expect(global.localStorage.setItem.calledWith(DEFAULT_MODE_STORAGE_KEY, 'light')).to.equal( + true, + ); fireEvent.click(screen.getByRole('button', { name: 'change to dark' })); - expect(global.localStorage.setItem.lastCall.args).to.eql([DEFAULT_STORAGE_KEY, 'dark']); + expect(global.localStorage.setItem.calledWith(DEFAULT_MODE_STORAGE_KEY, 'dark')).to.equal( + true, + ); }); - it('should use colorScheme from localStorage if exists', () => { - storage[DEFAULT_STORAGE_KEY] = 'dark'; + it('should use mode from localStorage if exists', () => { + storage[DEFAULT_MODE_STORAGE_KEY] = 'dark'; render( @@ -228,21 +243,21 @@ describe('createCssVarsProvider', () => { , ); - expect(screen.getByTestId('current-color-scheme').textContent).to.equal('dark'); + expect(screen.getByTestId('current-mode').textContent).to.equal('dark'); }); - it('use custom storageKey', () => { - const customStorageKey = 'foo-colorScheme'; - storage[customStorageKey] = 'dark'; + it('use custom modeStorageKey', () => { + const customModeStorageKey = 'foo-mode'; + storage[customModeStorageKey] = 'dark'; render( - + , ); - expect(screen.getByTestId('current-color-scheme').textContent).to.equal('dark'); - expect(global.localStorage.setItem.lastCall.args).to.eql([customStorageKey, 'dark']); + expect(screen.getByTestId('current-mode').textContent).to.equal('dark'); + expect(global.localStorage.setItem.calledWith(customModeStorageKey, 'dark')).to.equal(true); }); }); @@ -273,7 +288,7 @@ describe('createCssVarsProvider', () => { return
{theme.vars.color}
; }; it('use default color scheme if the storage value does not exist', () => { - storage[DEFAULT_STORAGE_KEY] = 'unknown'; + storage[DEFAULT_MODE_STORAGE_KEY] = 'unknown'; render( @@ -407,5 +422,65 @@ describe('createCssVarsProvider', () => { expect(screen.getByTestId('text').textContent).to.equal('var(--foo-bar-fontSize)'); }); + + it('`defaultMode` is specified', () => { + const { CssVarsProvider, useColorScheme } = createCssVarsProvider({ + theme: { + colorSchemes: { light: {}, dark: {} }, + }, + defaultColorScheme: 'light', + }); + const Text = () => { + const { mode } = useColorScheme(); + return
{mode}
; + }; + const { container } = render( + + + , + ); + expect(container.firstChild.textContent).to.equal('dark'); + }); + + it('`defaultColorScheme` is specified as string', () => { + const { CssVarsProvider, useColorScheme } = createCssVarsProvider({ + theme: { + colorSchemes: { light: {} }, + }, + defaultColorScheme: 'light', + }); + const Text = () => { + const { colorScheme } = useColorScheme(); + return
{colorScheme}
; + }; + const { container } = render( + + + , + ); + expect(container.firstChild.textContent).to.equal('paper'); + }); + + it('`defaultColorScheme` is specified as object', () => { + const { CssVarsProvider, useColorScheme } = createCssVarsProvider({ + theme: { + colorSchemes: { light: {} }, + }, + defaultColorScheme: 'light', + }); + const Text = () => { + const { colorScheme } = useColorScheme(); + return
{colorScheme}
; + }; + const { container } = render( + + + , + ); + expect(container.firstChild.textContent).to.equal('paper'); + }); }); }); diff --git a/packages/mui-system/src/cssVars/cssVarsParser.ts b/packages/mui-system/src/cssVars/cssVarsParser.ts index 4a7142f8dd9f4c..bcdf190eaad5da 100644 --- a/packages/mui-system/src/cssVars/cssVarsParser.ts +++ b/packages/mui-system/src/cssVars/cssVarsParser.ts @@ -96,12 +96,16 @@ const getCssValue = (keys: string[], value: string | number) => { * console.log(css) // { '--fontSize': '12px', '--lineHeight': 1.2, '--palette-primary-500': '#000000' } * console.log(vars) // { fontSize: '--fontSize', lineHeight: '--lineHeight', palette: { primary: { 500: 'var(--palette-primary-500)' } } } */ -export default function cssVarsParser(obj: Record, options?: { prefix?: string }) { +export default function cssVarsParser(theme: Record, options?: { prefix?: string }) { + const clonedTheme = { ...theme }; + + delete clonedTheme.vars; // remove 'vars' from the structure + const { prefix } = options || {}; const css = {} as NestedRecord; const vars = {} as NestedRecord; - walkObjectDeep(obj, (keys, value) => { + walkObjectDeep(clonedTheme, (keys, value) => { if (typeof value === 'string' || typeof value === 'number') { const cssVar = `--${prefix ? `${prefix}-` : ''}${keys.join('-')}`; Object.assign(css, { [cssVar]: getCssValue(keys, value) }); diff --git a/packages/mui-system/src/cssVars/getInitColorSchemeScript.tsx b/packages/mui-system/src/cssVars/getInitColorSchemeScript.tsx index 8a490c54f5e9f5..889a570da7e0e9 100644 --- a/packages/mui-system/src/cssVars/getInitColorSchemeScript.tsx +++ b/packages/mui-system/src/cssVars/getInitColorSchemeScript.tsx @@ -1,19 +1,47 @@ import * as React from 'react'; -export const DEFAULT_STORAGE_KEY = 'mui-color-scheme'; +export const DEFAULT_MODE_STORAGE_KEY = 'mui-mode'; +export const DEFAULT_COLOR_SCHEME_STORAGE_KEY = 'mui-color-scheme'; export const DEFAULT_ATTRIBUTE = 'data-mui-color-scheme'; export default function getInitColorSchemeScript(options?: { - storageKey?: string; + defaultMode?: 'light' | 'dark' | 'system'; + defaultLightColorScheme?: string; + defaultDarkColorScheme?: string; + modeStorageKey?: string; + colorSchemeStorageKey?: string; attribute?: string; }) { - const { storageKey = DEFAULT_STORAGE_KEY, attribute = DEFAULT_ATTRIBUTE } = options || {}; + const { + defaultMode = 'light', + defaultLightColorScheme = 'light', + defaultDarkColorScheme = 'dark', + modeStorageKey = DEFAULT_MODE_STORAGE_KEY, + colorSchemeStorageKey = DEFAULT_COLOR_SCHEME_STORAGE_KEY, + attribute = DEFAULT_ATTRIBUTE, + } = options || {}; return (