diff --git a/apps/pigment-css-next-app/next.config.js b/apps/pigment-css-next-app/next.config.js index 076069c4db48e5..22895eee1dc58e 100644 --- a/apps/pigment-css-next-app/next.config.js +++ b/apps/pigment-css-next-app/next.config.js @@ -81,32 +81,20 @@ const theme = extendTheme({ }, }, }, + getSelector: function getSelector(colorScheme, css) { + if (colorScheme) { + return { + [`@media (prefers-color-scheme: ${colorScheme})`]: { + ':root': css, + }, + }; + } + return ':root'; + }, }); - -// TODO: Fix this from the Material UI side in a separate PR -theme.palette = theme.colorSchemes.light.palette; theme.getColorSchemeSelector = (colorScheme) => { return `@media (prefers-color-scheme: ${colorScheme})`; }; -const { css: rootCss } = theme.generateCssVars(); -const { css: lightCss } = theme.generateCssVars('light'); -const { css: darkCss } = theme.generateCssVars('dark'); -theme.generateCssVars = (colorScheme) => { - if (colorScheme === 'dark') { - return { - css: darkCss, - selector: { - '@media (prefers-color-scheme: dark)': { - ':root': darkCss, - }, - }, - }; - } - if (colorScheme === 'light') { - return { css: lightCss, selector: ':root' }; - } - return { css: rootCss, selector: ':root' }; -}; /** * @type {PigmentOptions} diff --git a/apps/pigment-css-vite-app/vite.config.ts b/apps/pigment-css-vite-app/vite.config.ts index 814f407f2231db..192a3024d3ca39 100644 --- a/apps/pigment-css-vite-app/vite.config.ts +++ b/apps/pigment-css-vite-app/vite.config.ts @@ -4,32 +4,21 @@ import Pages from 'vite-plugin-pages'; import { pigment } from '@pigment-css/vite-plugin'; import { experimental_extendTheme as extendTheme } from '@mui/material/styles'; -const theme = extendTheme(); - -// TODO: Fix this from the Material UI side in a separate PR -theme.palette = theme.colorSchemes.light.palette; +const theme = extendTheme({ + getSelector: function getSelector(colorScheme, css) { + if (colorScheme) { + return { + [`@media (prefers-color-scheme: ${colorScheme})`]: { + ':root': css, + }, + }; + } + return ':root'; + }, +}); theme.getColorSchemeSelector = (colorScheme) => { return `@media (prefers-color-scheme: ${colorScheme})`; }; -const { css: rootCss } = theme.generateCssVars(); -const { css: lightCss } = theme.generateCssVars('light'); -const { css: darkCss } = theme.generateCssVars('dark'); -theme.generateCssVars = (colorScheme) => { - if (colorScheme === 'dark') { - return { - css: darkCss, - selector: { - '@media (prefers-color-scheme: dark)': { - ':root': darkCss, - }, - }, - }; - } - if (colorScheme === 'light') { - return { css: lightCss, selector: ':root' }; - } - return { css: rootCss, selector: ':root' }; -}; export default defineConfig({ plugins: [ diff --git a/docs/data/system/experimental-api/css-theme-variables/CreateCssVarsProvider.js b/docs/data/system/experimental-api/css-theme-variables/CreateCssVarsProvider.js index 29dc5a22612732..7a051d679ec189 100644 --- a/docs/data/system/experimental-api/css-theme-variables/CreateCssVarsProvider.js +++ b/docs/data/system/experimental-api/css-theme-variables/CreateCssVarsProvider.js @@ -35,7 +35,7 @@ const darkColorScheme = { }; function extendTheme({ cssVarPrefix = 'system-demo' } = {}) { - const { vars: themeVars, generateCssVars } = prepareCssVars( + const { vars: themeVars, ...params } = prepareCssVars( { colorSchemes: { light: lightColorScheme, @@ -54,11 +54,11 @@ function extendTheme({ cssVarPrefix = 'system-demo' } = {}) { // ... any other objects independent of color-scheme, // like fontSizes, spacing etc vars: themeVars, - generateCssVars, palette: { ...lightColorScheme.palette, colorScheme: 'light', }, + ...params, }; return theme; diff --git a/docs/data/system/experimental-api/css-theme-variables/CreateCssVarsProvider.tsx b/docs/data/system/experimental-api/css-theme-variables/CreateCssVarsProvider.tsx index 3ffe7c60179c68..489ad641a0bbc5 100644 --- a/docs/data/system/experimental-api/css-theme-variables/CreateCssVarsProvider.tsx +++ b/docs/data/system/experimental-api/css-theme-variables/CreateCssVarsProvider.tsx @@ -13,9 +13,7 @@ type Theme = { palette: { colorScheme: 'light' | 'dark'; } & (typeof lightColorScheme)['palette']; - vars: ReturnType['vars']; - generateCssVars: ReturnType['generateCssVars']; -}; +} & ReturnType; const lightColorScheme = { palette: { @@ -47,7 +45,7 @@ const darkColorScheme = { }; function extendTheme({ cssVarPrefix = 'system-demo' } = {}) { - const { vars: themeVars, generateCssVars } = prepareCssVars( + const { vars: themeVars, ...params } = prepareCssVars( { colorSchemes: { light: lightColorScheme, @@ -66,11 +64,11 @@ function extendTheme({ cssVarPrefix = 'system-demo' } = {}) { // ... any other objects independent of color-scheme, // like fontSizes, spacing etc vars: themeVars, - generateCssVars, palette: { ...lightColorScheme.palette, colorScheme: 'light', }, + ...params, }; return theme; diff --git a/packages/mui-joy/src/styles/defaultTheme.test.js b/packages/mui-joy/src/styles/defaultTheme.test.js index f45402d3a1629a..3e1ed26552a813 100644 --- a/packages/mui-joy/src/styles/defaultTheme.test.js +++ b/packages/mui-joy/src/styles/defaultTheme.test.js @@ -5,6 +5,9 @@ describe('defaultTheme', () => { it('the output contains required fields', () => { Object.keys(defaultTheme).forEach((field) => { expect([ + 'attribute', + 'colorSchemeSelector', + 'defaultColorScheme', 'breakpoints', 'components', 'colorSchemes', @@ -17,6 +20,7 @@ describe('defaultTheme', () => { 'palette', 'shadowRing', 'shadowChannel', + 'shadowOpacity', 'getCssVar', 'spacing', 'radius', @@ -30,7 +34,8 @@ describe('defaultTheme', () => { 'unstable_sxConfig', 'unstable_sx', 'shouldSkipGeneratingVar', - 'generateCssVars', + 'generateStyleSheets', + 'generateThemeVars', 'applyStyles', ]).to.includes(field); }); diff --git a/packages/mui-joy/src/styles/extendTheme.test.js b/packages/mui-joy/src/styles/extendTheme.test.js index 5a5986a8473319..2ad947c7e97a9c 100644 --- a/packages/mui-joy/src/styles/extendTheme.test.js +++ b/packages/mui-joy/src/styles/extendTheme.test.js @@ -8,9 +8,12 @@ describe('extendTheme', () => { const result = extendTheme(); Object.keys(result).forEach((field) => { expect([ + 'attribute', 'breakpoints', + 'colorSchemeSelector', 'components', 'colorSchemes', + 'defaultColorScheme', 'focus', 'fontSize', 'fontFamily', @@ -20,6 +23,9 @@ describe('extendTheme', () => { 'spacing', 'radius', 'shadow', + 'shadowRing', + 'shadowChannel', + 'shadowOpacity', 'zIndex', 'typography', 'variants', @@ -30,7 +36,8 @@ describe('extendTheme', () => { 'unstable_sxConfig', 'unstable_sx', 'shouldSkipGeneratingVar', - 'generateCssVars', + 'generateStyleSheets', + 'generateThemeVars', 'applyStyles', ]).to.includes(field); }); diff --git a/packages/mui-joy/src/styles/extendTheme.ts b/packages/mui-joy/src/styles/extendTheme.ts index b1e85193334932..acccaded9c5ab4 100644 --- a/packages/mui-joy/src/styles/extendTheme.ts +++ b/packages/mui-joy/src/styles/extendTheme.ts @@ -81,6 +81,26 @@ export interface CssVarsThemeOptions extends Partial2Level { * value = 'var(--test)' */ shouldSkipGeneratingVar?: (keys: string[], value: string | number) => boolean; + /** + * If provided, it will be used to create a selector for the color scheme. + * This is useful if you want to use class or data-* attributes to apply the color scheme. + * + * The callback receives the colorScheme with the possible values of: + * - undefined: the selector for tokens that are not color scheme dependent + * - string: the selector for the color scheme + * + * @example + * // class selector + * (colorScheme) => colorScheme !== 'light' ? `.theme-${colorScheme}` : ":root" + * + * @example + * // data-* attribute selector + * (colorScheme) => colorScheme !== 'light' ? `[data-theme="${colorScheme}"`] : ":root" + */ + getSelector?: ( + colorScheme: SupportedColorScheme | undefined, + css: Record, + ) => string | Record; } export const createGetCssVar = (cssVarPrefix = 'joy') => @@ -94,6 +114,7 @@ export default function extendTheme(themeOptions?: CssVarsThemeOptions): Theme { components: componentsInput, variants: variantsInput, shouldSkipGeneratingVar = defaultShouldSkipGeneratingVar, + getSelector, ...scalesInput } = themeOptions || {}; const getCssVar = createGetCssVar(cssVarPrefix); @@ -523,6 +544,7 @@ export default function extendTheme(themeOptions?: CssVarsThemeOptions): Theme { const theme = { colorSchemes, + defaultColorScheme: 'light', ...mergedScales, breakpoints: createBreakpoints(breakpoints ?? {}), components: deepmerge( @@ -565,7 +587,7 @@ export default function extendTheme(themeOptions?: CssVarsThemeOptions): Theme { cssVarPrefix, getCssVar, spacing: createSpacing(spacing), - } as unknown as Theme; // Need type casting due to module augmentation inside the repo + } as unknown as Theme & { attribute: string; colorSchemeSelector: string }; // Need type casting due to module augmentation inside the repo /** Color channels generation @@ -610,15 +632,29 @@ export default function extendTheme(themeOptions?: CssVarsThemeOptions): Theme { const parserConfig = { prefix: cssVarPrefix, shouldSkipGeneratingVar, + getSelector: + getSelector || + ((colorScheme) => { + if (theme.defaultColorScheme === colorScheme) { + return `${theme.colorSchemeSelector}, [${theme.attribute}="${colorScheme}"]`; + } + if (colorScheme) { + return `[${theme.attribute}="${colorScheme}"]`; + } + return theme.colorSchemeSelector; + }), }; - const { vars: themeVars, generateCssVars } = prepareCssVars( + const { vars, generateThemeVars, generateStyleSheets } = prepareCssVars( // @ts-ignore property truDark is missing from colorSchemes { colorSchemes, ...mergedScales }, parserConfig, ); - theme.vars = themeVars; - theme.generateCssVars = generateCssVars; + theme.attribute = 'data-joy-color-scheme'; + theme.colorSchemeSelector = ':root'; + theme.vars = vars; + theme.generateThemeVars = generateThemeVars; + theme.generateStyleSheets = generateStyleSheets; theme.unstable_sxConfig = { ...defaultSxConfig, ...themeOptions?.unstable_sxConfig, @@ -630,9 +666,7 @@ export default function extendTheme(themeOptions?: CssVarsThemeOptions): Theme { }); }; theme.getColorSchemeSelector = (colorScheme: SupportedColorScheme) => - colorScheme === 'light' - ? '&' - : `&[data-joy-color-scheme="${colorScheme}"], [data-joy-color-scheme="${colorScheme}"] &`; + `[${theme.attribute}="${colorScheme}"] &`; const createVariantInput = { getCssVar, palette: theme.colorSchemes.light.palette }; theme.variants = deepmerge( @@ -657,6 +691,10 @@ export default function extendTheme(themeOptions?: CssVarsThemeOptions): Theme { variantsInput, ); + Object.entries(theme.colorSchemes[theme.defaultColorScheme]).forEach(([key, value]) => { + // @ts-ignore + theme[key] = value; + }); theme.palette = { ...theme.colorSchemes.light.palette, colorScheme: 'light', diff --git a/packages/mui-joy/src/styles/types/theme.ts b/packages/mui-joy/src/styles/types/theme.ts index c5a711544383e3..511a5d61f8f025 100644 --- a/packages/mui-joy/src/styles/types/theme.ts +++ b/packages/mui-joy/src/styles/types/theme.ts @@ -97,6 +97,7 @@ export type ThemeCssVar = OverridableStringUnion, Theme export interface Theme extends ThemeScales, RuntimeColorSystem { colorSchemes: Record; + defaultColorScheme: DefaultColorScheme | ExtendedColorScheme; focus: Focus; typography: TypographySystem; variants: Variants; @@ -106,10 +107,8 @@ export interface Theme extends ThemeScales, RuntimeColorSystem { vars: ThemeVars; getCssVar: (field: ThemeCssVar, ...vars: ThemeCssVar[]) => string; getColorSchemeSelector: (colorScheme: DefaultColorScheme | ExtendedColorScheme) => string; - generateCssVars: (colorScheme?: DefaultColorScheme | ExtendedColorScheme) => { - css: Record; - vars: ThemeVars; - }; + generateThemeVars: () => ThemeVars; + generateStyleSheets: () => Record[]; /** * A function to determine if the key, value should be attached as CSS Variable * `keys` is an array that represents the object path keys. diff --git a/packages/mui-material/src/styles/CssVarsProvider.test.js b/packages/mui-material/src/styles/CssVarsProvider.test.js index 8b34b5b546cdee..49f262ed817c3c 100644 --- a/packages/mui-material/src/styles/CssVarsProvider.test.js +++ b/packages/mui-material/src/styles/CssVarsProvider.test.js @@ -140,9 +140,9 @@ describe('[Material UI] CssVarsProvider', () => { primary: 'var(--mui-palette-text-primary)', secondary: 'var(--mui-palette-text-secondary)', disabled: 'var(--mui-palette-text-disabled)', + icon: 'var(--mui-palette-text-icon)', primaryChannel: 'var(--mui-palette-text-primaryChannel)', secondaryChannel: 'var(--mui-palette-text-secondaryChannel)', - icon: 'var(--mui-palette-text-icon)', }), ); expect(screen.getByTestId('palette-divider').textContent).to.equal( diff --git a/packages/mui-material/src/styles/CssVarsProvider.tsx b/packages/mui-material/src/styles/CssVarsProvider.tsx index 75f5c6e6f18154..e7779777a3094c 100644 --- a/packages/mui-material/src/styles/CssVarsProvider.tsx +++ b/packages/mui-material/src/styles/CssVarsProvider.tsx @@ -10,7 +10,6 @@ import experimental_extendTheme, { CssVarsTheme, } from './experimental_extendTheme'; import createTypography from './createTypography'; -import excludeVariablesFromRoot from './excludeVariablesFromRoot'; import THEME_ID from './identifier'; const defaultTheme = experimental_extendTheme(); @@ -40,7 +39,6 @@ const { CssVarsProvider, useColorScheme, getInitColorSchemeScript } = createCssV return newTheme; }, - excludeVariablesFromRoot, }); export { diff --git a/packages/mui-material/src/styles/createGetSelector.ts b/packages/mui-material/src/styles/createGetSelector.ts new file mode 100644 index 00000000000000..b5eff828009ad9 --- /dev/null +++ b/packages/mui-material/src/styles/createGetSelector.ts @@ -0,0 +1,33 @@ +import excludeVariablesFromRoot from './excludeVariablesFromRoot'; + +export default < + T extends { + attribute: string; + colorSchemeSelector: string; + colorSchemes?: Record; + defaultColorScheme?: string; + cssVarPrefix?: string; + }, + >( + theme: T, + ) => + (colorScheme: keyof T['colorSchemes'] | undefined, css: Record) => { + if (theme.defaultColorScheme === colorScheme) { + if (colorScheme === 'dark') { + const excludedVariables: typeof css = {}; + excludeVariablesFromRoot(theme.cssVarPrefix).forEach((cssVar) => { + excludedVariables[cssVar] = css[cssVar]; + delete css[cssVar]; + }); + return { + [`[${theme.attribute}="${String(colorScheme)}"]`]: excludedVariables, + [theme.colorSchemeSelector!]: css, + }; + } + return `${theme.colorSchemeSelector}, [${theme.attribute}="${String(colorScheme)}"]`; + } + if (colorScheme) { + return `[${theme.attribute}="${String(colorScheme)}"]`; + } + return theme.colorSchemeSelector; + }; diff --git a/packages/mui-material/src/styles/excludeVariablesFromRoot.ts b/packages/mui-material/src/styles/excludeVariablesFromRoot.ts index 415ff2af84f850..6041d0f1547fcf 100644 --- a/packages/mui-material/src/styles/excludeVariablesFromRoot.ts +++ b/packages/mui-material/src/styles/excludeVariablesFromRoot.ts @@ -1,7 +1,7 @@ /** * @internal These variables should not appear in the :root stylesheet when the `defaultMode="dark"` */ -const excludeVariablesFromRoot = (cssVarPrefix: string) => [ +const excludeVariablesFromRoot = (cssVarPrefix?: string) => [ ...[...Array(24)].map( (_, index) => `--${cssVarPrefix ? `${cssVarPrefix}-` : ''}overlays-${index + 1}`, ), diff --git a/packages/mui-material/src/styles/experimental_extendTheme.d.ts b/packages/mui-material/src/styles/experimental_extendTheme.d.ts index e2d45afbbc7b93..ea83de510f8959 100644 --- a/packages/mui-material/src/styles/experimental_extendTheme.d.ts +++ b/packages/mui-material/src/styles/experimental_extendTheme.d.ts @@ -289,6 +289,26 @@ export interface CssVarsThemeOptions extends Omit>; + /** + * If provided, it will be used to create a selector for the color scheme. + * This is useful if you want to use class or data-* attributes to apply the color scheme. + * + * The callback receives the colorScheme with the possible values of: + * - undefined: the selector for tokens that are not color scheme dependent + * - string: the selector for the color scheme + * + * @example + * // class selector + * (colorScheme) => colorScheme !== 'light' ? `.theme-${colorScheme}` : ":root" + * + * @example + * // data-* attribute selector + * (colorScheme) => colorScheme !== 'light' ? `[data-theme="${colorScheme}"`] : ":root" + */ + getSelector?: ( + colorScheme: SupportedColorScheme | undefined, + css: Record, + ) => string | Record; /** * A function to determine if the key, value should be attached as CSS Variable * `keys` is an array that represents the object path keys. @@ -409,10 +429,8 @@ export interface CssVarsTheme extends ColorSystem { vars: ThemeVars; getCssVar: (field: ThemeCssVar, ...vars: ThemeCssVar[]) => string; getColorSchemeSelector: (colorScheme: SupportedColorScheme) => string; - generateCssVars: (colorScheme?: SupportedColorScheme) => { - css: Record; - vars: ThemeVars; - }; + generateThemeVars: () => ThemeVars; + generateStyleSheets: () => Array>; // Default theme tokens spacing: Theme['spacing']; @@ -446,4 +464,4 @@ export interface CssVarsTheme extends ColorSystem { export default function experimental_extendTheme( options?: CssVarsThemeOptions, ...args: object[] -): Omit & CssVarsTheme; +): Omit & CssVarsTheme; diff --git a/packages/mui-material/src/styles/experimental_extendTheme.js b/packages/mui-material/src/styles/experimental_extendTheme.js index 10bfdb80e22920..c04bbb28a03bd6 100644 --- a/packages/mui-material/src/styles/experimental_extendTheme.js +++ b/packages/mui-material/src/styles/experimental_extendTheme.js @@ -19,6 +19,7 @@ import { import defaultShouldSkipGeneratingVar from './shouldSkipGeneratingVar'; import createThemeWithoutVars from './createTheme'; import getOverlayAlpha from './getOverlayAlpha'; +import defaultGetSelector from './createGetSelector'; const defaultDarkOverlays = [...Array(25)].map((_, index) => { if (index === 0) { @@ -78,6 +79,7 @@ export default function extendTheme(options = {}, ...args) { colorSchemes: colorSchemesInput = {}, cssVarPrefix = 'mui', shouldSkipGeneratingVar = defaultShouldSkipGeneratingVar, + getSelector, ...input } = options; const getCssVar = createGetCssVar(cssVarPrefix); @@ -91,6 +93,7 @@ export default function extendTheme(options = {}, ...args) { }); let theme = { + defaultColorScheme: 'light', ...muiTheme, cssVarPrefix, getCssVar, @@ -400,11 +403,18 @@ export default function extendTheme(options = {}, ...args) { const parserConfig = { prefix: cssVarPrefix, shouldSkipGeneratingVar, + getSelector: getSelector || defaultGetSelector(theme), }; - const { vars: themeVars, generateCssVars } = prepareCssVars(theme, parserConfig); - theme.vars = themeVars; - theme.generateCssVars = generateCssVars; - + const { vars, generateThemeVars, generateStyleSheets } = prepareCssVars(theme, parserConfig); + theme.attribute = 'data-mui-color-scheme'; + theme.colorSchemeSelector = ':root'; + theme.vars = vars; + Object.entries(theme.colorSchemes[theme.defaultColorScheme]).forEach(([key, value]) => { + theme[key] = value; + }); + theme.generateThemeVars = generateThemeVars; + theme.generateStyleSheets = generateStyleSheets; + theme.getColorSchemeSelector = (colorScheme) => `[${theme.attribute}="${colorScheme}"] &`; theme.shouldSkipGeneratingVar = shouldSkipGeneratingVar; theme.unstable_sxConfig = { ...defaultSxConfig, diff --git a/packages/mui-material/src/styles/experimental_extendTheme.test.js b/packages/mui-material/src/styles/experimental_extendTheme.test.js index d90f899199d140..1860fb4508ab99 100644 --- a/packages/mui-material/src/styles/experimental_extendTheme.test.js +++ b/packages/mui-material/src/styles/experimental_extendTheme.test.js @@ -532,4 +532,25 @@ describe('experimental_extendTheme', () => { }, }); }); + + it("should `generateStyleSheets` based on the theme's attribute and colorSchemeSelector", () => { + const theme = extendTheme(); + + expect(theme.generateStyleSheets().flatMap((sheet) => Object.keys(sheet))).to.deep.equal([ + ':root', + ':root, [data-mui-color-scheme="light"]', + '[data-mui-color-scheme="dark"]', + ]); + + theme.attribute = 'data-custom-color-scheme'; + theme.colorSchemeSelector = '.root'; + theme.defaultColorScheme = 'dark'; + + expect(theme.generateStyleSheets().flatMap((sheet) => Object.keys(sheet))).to.deep.equal([ + '.root', + '[data-custom-color-scheme="dark"]', + '.root', + '[data-custom-color-scheme="light"]', + ]); + }); }); diff --git a/packages/mui-system/src/cssVars/createCssVarsProvider.d.ts b/packages/mui-system/src/cssVars/createCssVarsProvider.d.ts index a3219951a3bef5..b8dc3ff9a2d703 100644 --- a/packages/mui-system/src/cssVars/createCssVarsProvider.d.ts +++ b/packages/mui-system/src/cssVars/createCssVarsProvider.d.ts @@ -142,14 +142,6 @@ export default function createCssVarsProvider< * variants from those tokens. */ resolveTheme?: (theme: any) => any; // the type is any because it depends on the design system. - /** - * @internal - * A function that returns a list of variables that will be excluded from the `colorSchemeSelector` (:root by default) - * - * Some variables are intended to be used in a specific color scheme only. They should be excluded when the default mode is set to the color scheme. - * This is introduced to fix https://github.com/mui/material-ui/issues/34084 - */ - excludeVariablesFromRoot?: (cssVarPrefix: string) => string[]; }, ): CreateCssVarsProviderResult; diff --git a/packages/mui-system/src/cssVars/createCssVarsProvider.js b/packages/mui-system/src/cssVars/createCssVarsProvider.js index e4969c4ad5b44e..5f218509fc0be9 100644 --- a/packages/mui-system/src/cssVars/createCssVarsProvider.js +++ b/packages/mui-system/src/cssVars/createCssVarsProvider.js @@ -1,7 +1,6 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import MuiError from '@mui/internal-babel-macros/MuiError.macro'; -import deepmerge from '@mui/utils/deepmerge'; import { GlobalStyles } from '@mui/styled-engine'; import { useTheme as muiUseTheme } from '@mui/private-theming'; import ThemeProvider from '../ThemeProvider'; @@ -32,7 +31,6 @@ export default function createCssVarsProvider(options) { defaultColorScheme: designSystemColorScheme, disableTransitionOnChange: designSystemTransitionOnChange = false, resolveTheme, - excludeVariablesFromRoot, } = options; if ( @@ -86,7 +84,6 @@ export default function createCssVarsProvider(options) { const { colorSchemes = {}, components = {}, - generateCssVars = () => ({ vars: {}, css: {} }), cssVarPrefix, ...restThemeProp } = scopedTheme || themeProp; @@ -145,8 +142,8 @@ export default function createCssVarsProvider(options) { return colorScheme; })(); - // 2. Create CSS variables and store them in objects (to be generated in stylesheets in the final step) - const { css: rootCss, vars: rootVars } = generateCssVars(); + // 2. get the `vars` object that refers to the CSS custom properties + const themeVars = restThemeProp.generateThemeVars?.() || restThemeProp.vars; // 3. Start composing the theme object const theme = { @@ -154,18 +151,11 @@ export default function createCssVarsProvider(options) { components, colorSchemes, cssVarPrefix, - vars: rootVars, - getColorSchemeSelector: (targetColorScheme) => `[${attribute}="${targetColorScheme}"] &`, + vars: themeVars, }; - // 4. Create color CSS variables and store them in objects (to be generated in stylesheets in the final step) - // The default color scheme stylesheet is constructed to have the least CSS specificity. - // The other color schemes uses selector, default as data attribute, to increase the CSS specificity so that they can override the default color scheme stylesheet. - const defaultColorSchemeStyleSheet = {}; - const otherColorSchemesStyleSheet = {}; + // 4. Resolve the color scheme and merge it to the theme Object.entries(colorSchemes).forEach(([key, scheme]) => { - const { css, vars } = generateCssVars(key); - theme.vars = deepmerge(theme.vars, vars); if (key === calculatedColorScheme) { // 4.1 Merge the selected color scheme to the theme Object.keys(scheme).forEach((schemeKey) => { @@ -183,33 +173,24 @@ export default function createCssVarsProvider(options) { theme.palette.colorScheme = key; } } - const resolvedDefaultColorScheme = (() => { - if (typeof defaultColorScheme === 'string') { - return defaultColorScheme; - } - if (defaultMode === 'dark') { - return defaultColorScheme.dark; - } - return defaultColorScheme.light; - })(); - if (key === resolvedDefaultColorScheme) { - if (excludeVariablesFromRoot) { - const excludedVariables = {}; - excludeVariablesFromRoot(cssVarPrefix).forEach((cssVar) => { - excludedVariables[cssVar] = css[cssVar]; - delete css[cssVar]; - }); - defaultColorSchemeStyleSheet[`[${attribute}="${key}"]`] = excludedVariables; - } - defaultColorSchemeStyleSheet[`${colorSchemeSelector}, [${attribute}="${key}"]`] = css; - } else { - otherColorSchemesStyleSheet[ - `${colorSchemeSelector === ':root' ? '' : colorSchemeSelector}[${attribute}="${key}"]` - ] = css; - } }); + const resolvedDefaultColorScheme = (() => { + if (typeof defaultColorScheme === 'string') { + return defaultColorScheme; + } + if (defaultMode === 'dark') { + return defaultColorScheme.dark; + } + return defaultColorScheme.light; + })(); + themeProp.defaultColorScheme = resolvedDefaultColorScheme; + themeProp.colorSchemeSelector = colorSchemeSelector; + themeProp.attribute = attribute; - theme.vars = deepmerge(theme.vars, rootVars); + if (!theme.getColorSchemeSelector) { + theme.getColorSchemeSelector = (targetColorScheme) => + `[${attribute}="${targetColorScheme}"] &`; + } // 5. Declaring effects // 5.1 Updates the selector value to use the current color scheme which tells CSS to use the proper stylesheet. @@ -279,9 +260,9 @@ export default function createCssVarsProvider(options) { {shouldGenerateStyleSheet && ( - - - + {(theme.generateStyleSheets?.() || []).map((styles, index) => ( + + ))} )} { , ); - expect(document.head.children[document.head.children.length - 1].textContent).not.to.equal( + expect(document.head.children[document.head.children.length - 1]?.textContent).not.to.equal( DISABLE_CSS_TRANSITION, ); fireEvent.click(screen.getByRole('button', { name: 'change to dark' })); - expect(document.head.children[document.head.children.length - 1].textContent).to.equal( + expect(document.head.children[document.head.children.length - 1]?.textContent).to.equal( DISABLE_CSS_TRANSITION, ); expect(screen.getByTestId('current-mode').textContent).to.equal('dark'); clock.runToLast(); - expect(document.head.children[document.head.children.length - 1].textContent).not.to.equal( + expect(document.head.children[document.head.children.length - 1]?.textContent).not.to.equal( DISABLE_CSS_TRANSITION, ); }); @@ -315,17 +315,17 @@ describe('createCssVarsProvider', () => { , ); - expect(document.head.children[document.head.children.length - 1].textContent).not.to.equal( + expect(document.head.children[document.head.children.length - 1]?.textContent).not.to.equal( DISABLE_CSS_TRANSITION, ); fireEvent.click(screen.getByRole('button', { name: 'change to dark' })); - expect(document.head.children[document.head.children.length - 1].textContent).to.equal( + expect(document.head.children[document.head.children.length - 1]?.textContent).to.equal( DISABLE_CSS_TRANSITION, ); expect(screen.getByTestId('current-color-scheme').textContent).to.equal('dark'); clock.runToLast(); - expect(document.head.children[document.head.children.length - 1].textContent).not.to.equal( + expect(document.head.children[document.head.children.length - 1]?.textContent).not.to.equal( DISABLE_CSS_TRANSITION, ); }); @@ -353,11 +353,11 @@ describe('createCssVarsProvider', () => { , ); - expect(document.head.children[document.head.children.length - 1].textContent).not.to.equal( + expect(document.head.children[document.head.children.length - 1]?.textContent).not.to.equal( DISABLE_CSS_TRANSITION, ); fireEvent.click(screen.getByRole('button', { name: 'change to dark' })); - expect(document.head.children[document.head.children.length - 1].textContent).not.to.equal( + expect(document.head.children[document.head.children.length - 1]?.textContent).not.to.equal( DISABLE_CSS_TRANSITION, ); expect(screen.getByTestId('current-mode').textContent).to.equal('dark'); @@ -386,11 +386,11 @@ describe('createCssVarsProvider', () => { , ); - expect(document.head.children[document.head.children.length - 1].textContent).not.to.equal( + expect(document.head.children[document.head.children.length - 1]?.textContent).not.to.equal( DISABLE_CSS_TRANSITION, ); fireEvent.click(screen.getByRole('button', { name: 'change to dark' })); - expect(document.head.children[document.head.children.length - 1].textContent).not.to.equal( + expect(document.head.children[document.head.children.length - 1]?.textContent).not.to.equal( DISABLE_CSS_TRANSITION, ); expect(screen.getByTestId('current-color-scheme').textContent).to.equal('dark'); diff --git a/packages/mui-system/src/cssVars/createCssVarsTheme.ts b/packages/mui-system/src/cssVars/createCssVarsTheme.ts index 7400521b1a9c04..55807886fbfbbb 100644 --- a/packages/mui-system/src/cssVars/createCssVarsTheme.ts +++ b/packages/mui-system/src/cssVars/createCssVarsTheme.ts @@ -6,15 +6,19 @@ interface Theme extends DefaultCssVarsTheme { } function createCssVarsTheme>(theme: T) { - const { cssVarPrefix, shouldSkipGeneratingVar, ...otherTheme } = theme; + const output: any = theme; + const result = prepareCssVars, ThemeVars>( + output, + { + ...theme, + prefix: theme.cssVarPrefix, + }, + ); + output.vars = result.vars; + output.generateThemeVars = result.generateThemeVars; + output.generateStyleSheets = result.generateStyleSheets; - return { - ...theme, - ...prepareCssVars, ThemeVars>(otherTheme, { - prefix: cssVarPrefix, - shouldSkipGeneratingVar, - }), - }; + return output as T & typeof result; } export default createCssVarsTheme; diff --git a/packages/mui-system/src/cssVars/prepareCssVars.test.ts b/packages/mui-system/src/cssVars/prepareCssVars.test.ts index 4de3389c90cd12..09a6c267f2a4b7 100644 --- a/packages/mui-system/src/cssVars/prepareCssVars.test.ts +++ b/packages/mui-system/src/cssVars/prepareCssVars.test.ts @@ -2,7 +2,43 @@ import { expect } from 'chai'; import prepareCssVars from './prepareCssVars'; describe('prepareCssVars', () => { - it('`generateCssVars` should always return a new object', () => { + it('`getSelector` should always get a fresh copy of the css', () => { + const result = prepareCssVars( + { + colorSchemes: { + light: { + color: 'red', + }, + dark: { + color: 'blue', + }, + }, + }, + { + getSelector: (colorScheme, css) => { + const color = css['--color']; + delete css['--color']; + return { + [`.${colorScheme}`]: { + background: color, + }, + }; + }, + }, + ); + expect(result.generateStyleSheets()).to.deep.equal([ + { '.light': { background: 'red' } }, + { '.dark': { background: 'blue' } }, + ]); + + // run again should have the same result + expect(result.generateStyleSheets()).to.deep.equal([ + { '.light': { background: 'red' } }, + { '.dark': { background: 'blue' } }, + ]); + }); + + it('delete css fields should not affect the next call', () => { const result = prepareCssVars({ colorSchemes: { dark: { @@ -11,29 +47,160 @@ describe('prepareCssVars', () => { }, }); - const { css: css1 } = result.generateCssVars('dark'); - const { css: css2 } = result.generateCssVars('dark'); + const css1 = result.generateStyleSheets(); + + delete css1[0]['[data-color-scheme="dark"]']; + + expect(css1[0]).to.deep.equal({}); - expect(css1).to.not.equal(css2); + const css2 = result.generateStyleSheets(); + + expect(css2[0]).to.deep.equal({ '[data-color-scheme="dark"]': { '--color': 'red' } }); }); - it('delete css fields should not affect the next call', () => { + it('produce theme vars with defaults', () => { const result = prepareCssVars({ + defaultColorScheme: 'dark', colorSchemes: { dark: { color: 'red', }, + light: { + color: 'green', + }, + }, + fontSize: { + base: '1rem', + }, + }); + expect(result.vars).to.deep.equal({ + color: 'var(--color, red)', + fontSize: { + base: 'var(--fontSize-base, 1rem)', }, }); + }); - const { css: css1 } = result.generateCssVars('dark'); + it('`generateThemeVars` should have the right structure', () => { + const result = prepareCssVars({ + defaultColorScheme: 'dark', + colorSchemes: { + dark: { + color: 'red', + }, + light: { + color: 'green', + }, + }, + fontSize: { + base: '1rem', + }, + }); + expect(result.generateThemeVars()).to.deep.equal({ + color: 'var(--color)', + fontSize: { + base: 'var(--fontSize-base)', + }, + }); + }); - delete css1['--color']; + it('`generateThemeVars` should have the provided prefix', () => { + const result = prepareCssVars( + { + defaultColorScheme: 'dark', + colorSchemes: { + dark: { + color: 'red', + }, + light: { + color: 'green', + }, + }, + fontSize: { + base: '1rem', + }, + }, + { prefix: 'mui' }, + ); + expect(result.generateThemeVars()).to.deep.equal({ + color: 'var(--mui-color)', + fontSize: { + base: 'var(--mui-fontSize-base)', + }, + }); + }); - expect(css1).to.deep.equal({}); + it('`generateStyleSheets` should have the right sequence', () => { + const result = prepareCssVars({ + defaultColorScheme: 'dark', + colorSchemes: { + dark: { + color: 'red', + }, + light: { + color: 'green', + }, + }, + fontSize: { + base: '1rem', + }, + }); + + const stylesheets = result.generateStyleSheets(); + expect(stylesheets).to.deep.equal([ + { ':root': { '--fontSize-base': '1rem' } }, + { '[data-color-scheme="dark"]': { '--color': 'red' } }, + { '[data-color-scheme="light"]': { '--color': 'green' } }, + ]); + }); - const { css: css2 } = result.generateCssVars('dark'); + it('`generateStyleSheets` respect the `getSelector` input', () => { + const result = prepareCssVars( + { + defaultColorScheme: 'dark', + colorSchemes: { + dark: { + color: 'red', + background: '#000', + }, + light: { + color: 'green', + background: '#fff', + }, + }, + fontSize: { + base: '1rem', + }, + }, + { + prefix: 'mui', + getSelector: (colorScheme, css) => { + if (colorScheme === 'dark') { + const exclusion: Record = {}; + Object.keys(css).forEach((key) => { + if (key.endsWith('background')) { + exclusion[key] = css[key]; + delete css[key]; + } + }); + return { + '.dark': exclusion, + '.root, .dark': css, + }; + } + if (colorScheme) { + return `.${colorScheme}`; + } + return '.root'; + }, + }, + ); - expect(css2).to.deep.equal({ '--color': 'red' }); + const stylesheets = result.generateStyleSheets(); + expect(stylesheets).to.deep.equal([ + { '.root': { '--mui-fontSize-base': '1rem' } }, + { '.dark': { '--mui-background': '#000' }, '.root, .dark': { '--mui-color': 'red' } }, + { '.light': { '--mui-color': 'green', '--mui-background': '#fff' } }, + ]); }); }); diff --git a/packages/mui-system/src/cssVars/prepareCssVars.ts b/packages/mui-system/src/cssVars/prepareCssVars.ts index a9d5e456406ec1..b587266ad57c25 100644 --- a/packages/mui-system/src/cssVars/prepareCssVars.ts +++ b/packages/mui-system/src/cssVars/prepareCssVars.ts @@ -2,21 +2,24 @@ import deepmerge from '@mui/utils/deepmerge'; import cssVarsParser from './cssVarsParser'; export interface DefaultCssVarsTheme { + attribute?: string; colorSchemes?: Record; defaultColorScheme?: string; } -function prepareCssVars< - T extends DefaultCssVarsTheme, - ThemeVars extends Record, - Selector = any, ->( +function prepareCssVars>( theme: T, - parserConfig?: { + { + getSelector, + ...parserConfig + }: { prefix?: string; shouldSkipGeneratingVar?: (objectPathKeys: Array, value: string | number) => boolean; - getSelector?: (colorScheme: string | undefined, css: Record) => Selector; - }, + getSelector?: ( + colorScheme: keyof T['colorSchemes'] | undefined, + css: Record, + ) => string | Record; + } = {}, ) { // @ts-ignore - ignore components do not exist const { colorSchemes = {}, components, defaultColorScheme = 'light', ...otherTheme } = theme; @@ -29,39 +32,64 @@ function prepareCssVars< const colorSchemesMap: Record; vars: ThemeVars }> = {}; - const { [defaultColorScheme]: light, ...otherColorSchemes } = colorSchemes; + const { [defaultColorScheme]: defaultScheme, ...otherColorSchemes } = colorSchemes; Object.entries(otherColorSchemes || {}).forEach(([key, scheme]) => { const { vars, css, varsWithDefaults } = cssVarsParser(scheme, parserConfig); themeVars = deepmerge(themeVars, varsWithDefaults); colorSchemesMap[key] = { css, vars }; }); - if (light) { + if (defaultScheme) { // default color scheme vars should be merged last to set as default - const { css, vars, varsWithDefaults } = cssVarsParser(light, parserConfig); + const { css, vars, varsWithDefaults } = cssVarsParser(defaultScheme, parserConfig); themeVars = deepmerge(themeVars, varsWithDefaults); colorSchemesMap[defaultColorScheme] = { css, vars }; } - const generateCssVars = (colorScheme?: string) => { - if (!colorScheme) { - const css = { ...rootCss }; - return { + const generateThemeVars = () => { + let vars = { ...rootVars }; + Object.entries(colorSchemesMap).forEach(([, { vars: schemeVars }]) => { + vars = deepmerge(vars, schemeVars); + }); + return vars; + }; + + const generateStyleSheets = () => { + const stylesheets: Array> = []; + const colorScheme = theme.defaultColorScheme || 'light'; + function insertStyleSheet(selector: string | object, css: Record) { + if (Object.keys(css).length) { + stylesheets.push(typeof selector === 'string' ? { [selector]: { ...css } } : selector); + } + } + insertStyleSheet(getSelector?.(undefined, { ...rootCss }) || ':root', rootCss); + + const { [colorScheme]: defaultSchemeVal, ...rest } = colorSchemesMap; + + if (defaultSchemeVal) { + // default color scheme has to come before other color schemes + const { css } = defaultSchemeVal; + insertStyleSheet( + getSelector?.(colorScheme as keyof T['colorSchemes'], { ...css }) || + `[${theme.attribute || 'data-color-scheme'}="${colorScheme}"]`, css, - vars: rootVars, - selector: parserConfig?.getSelector?.(colorScheme, css) || ':root', - }; + ); } - const css = { ...colorSchemesMap[colorScheme].css }; - return { - css, - vars: colorSchemesMap[colorScheme].vars, - selector: parserConfig?.getSelector?.(colorScheme, css) || ':root', - }; + + Object.entries(rest).forEach(([key, { css }]) => { + insertStyleSheet( + getSelector?.(key as keyof T['colorSchemes'], { ...css }) || + `[${theme.attribute || 'data-color-scheme'}="${key}"]`, + css, + ); + }); + + return stylesheets; }; return { vars: themeVars, - generateCssVars, + generateThemeVars, + generateStyleSheets, }; } diff --git a/packages/pigment-css-react/src/utils/extendTheme.ts b/packages/pigment-css-react/src/utils/extendTheme.ts index 8f77682c9427a0..aab9ac6607a01d 100644 --- a/packages/pigment-css-react/src/utils/extendTheme.ts +++ b/packages/pigment-css-react/src/utils/extendTheme.ts @@ -1,9 +1,8 @@ -import deepMerge from 'lodash/merge'; import { prepareCssVars } from '@mui/system/cssVars'; import type { SxConfig } from '@mui/system/styleFunctionSx'; import type { CSSObject } from '../base'; -export interface ThemeInput { +export interface ThemeInput extends Record { /** * The prefix to be used for the CSS variables. */ @@ -22,15 +21,17 @@ export interface ThemeInput { * If provided, it will be used to create a selector for the color scheme. * This is useful if you want to use class or data-* attributes to apply the color scheme. * - * The default selector is `:root`. + * The callback receives the colorScheme with the possible values of: + * - undefined: the selector for tokens that are not color scheme dependent + * - string: the selector for the color scheme * * @example * // class selector - * (colorScheme) => colorScheme ? `.theme-${colorScheme}` : ":root" + * (colorScheme) => colorScheme !== 'light' ? `.theme-${colorScheme}` : ":root" * * @example * // data-* attribute selector - * (colorScheme) => colorScheme ? `[data-theme="${colorScheme}"`] : ":root" + * (colorScheme) => colorScheme !== 'light' ? `[data-theme="${colorScheme}"`] : ":root" */ getSelector?: ( colorScheme: ColorScheme | undefined, @@ -74,14 +75,11 @@ export type ExtendTheme< styles: CSSObject, ) => Record>; getColorSchemeSelector: (colorScheme: Options['colorScheme']) => string; - generateCssVars: (colorScheme?: Options['colorScheme']) => { - css: Record; - selector: string | Record; - }; + generateStyleSheets: () => Array>; unstable_sxConfig?: SxConfig; }; -export type Theme = ExtendTheme; +export type Theme = Record; /** * A utility to tell zero-runtime to generate CSS variables for the theme. @@ -140,18 +138,13 @@ export function extendTheme< shouldSkipGeneratingVar, getSelector, }; - const { generateCssVars } = prepareCssVars(otherTheme, parserConfig); - - let { vars } = generateCssVars(); - Object.entries(theme.colorSchemes || {}).forEach(([key]) => { - vars = deepMerge(vars, generateCssVars(key).vars); - }); + const { generateThemeVars, generateStyleSheets } = prepareCssVars(otherTheme, parserConfig); const finalTheme = { ...theme, defaultColorScheme, - vars, - generateCssVars, + vars: generateThemeVars(), + generateStyleSheets, } as unknown as ExtendTheme<{ colorScheme: Options['colorScheme']; tokens: Options['tokens'] }>; finalTheme.getColorSchemeSelector = (colorScheme: string) => { diff --git a/packages/pigment-css-react/src/utils/generateCss.ts b/packages/pigment-css-react/src/utils/generateCss.ts index afe1f670d931e9..99203736139704 100644 --- a/packages/pigment-css-react/src/utils/generateCss.ts +++ b/packages/pigment-css-react/src/utils/generateCss.ts @@ -2,34 +2,8 @@ import { serializeStyles } from '@emotion/serialize'; import { Theme } from './extendTheme'; export function generateTokenCss(theme: Theme) { - // create stylesheet as object - const { css: rootCss, selector: rootSelector } = theme.generateCssVars(); - const stylesheets: Array> = []; - if (Object.keys(rootCss).length) { - stylesheets.push(typeof rootSelector === 'string' ? { [rootSelector]: rootCss } : rootSelector); - } - if (theme.colorSchemes) { - const { [theme.defaultColorScheme!]: defaultScheme, ...otherColorSchemes } = theme.colorSchemes; - - if (defaultScheme) { - // need to generate default color scheme first for the prefers-color-scheme media query to work - // because media-queries does not increase specificity - const { css, selector } = theme.generateCssVars(theme.defaultColorScheme); - if (Object.keys(css).length) { - stylesheets.push(typeof selector === 'string' ? { [selector]: css } : selector); - } - } - - Object.entries(otherColorSchemes).forEach(([key]) => { - const { css, selector } = theme.generateCssVars(key); - if (Object.keys(css).length) { - stylesheets.push(typeof selector === 'string' ? { [selector]: css } : selector); - } - }); - } - // use emotion to serialize the object to css string - const { styles } = serializeStyles(stylesheets); + const { styles } = serializeStyles(theme.generateStyleSheets?.() || []); return styles; } diff --git a/packages/pigment-css-react/tests/utils/theme-tokens.test.ts b/packages/pigment-css-react/tests/utils/theme-tokens.test.ts new file mode 100644 index 00000000000000..8ea8478919c788 --- /dev/null +++ b/packages/pigment-css-react/tests/utils/theme-tokens.test.ts @@ -0,0 +1,102 @@ +import { extendTheme, generateTokenCss, generateThemeTokens } from '@pigment-css/react/utils'; +import { expect } from 'chai'; + +describe('theme-tokens', () => { + describe('generateTokenCss', () => { + it('should work with plain theme', () => { + expect( + generateTokenCss({ + colors: { + primary: 'red', + secondary: 'blue', + }, + fontSizes: [12, 14, 16, 20, 24, 32], + }), + ).to.equal(''); + }); + + it('should generate stylesheet correctly', () => { + expect( + generateTokenCss( + extendTheme({ + colorSchemes: { + light: { + palette: { + primary: 'red', + secondary: 'blue', + }, + }, + dark: { + palette: { + primary: 'darkred', + secondary: 'darkblue', + }, + }, + }, + radius: { + xs: 4, + sm: 8, + md: 16, + }, + }), + ), + ).to.equal( + ':root{--radius-xs:4px;--radius-sm:8px;--radius-md:16px;}:root{--palette-primary:red;--palette-secondary:blue;}@media (prefers-color-scheme: dark){:root{--palette-primary:darkred;--palette-secondary:darkblue;}}', + ); + }); + }); + + describe('generateThemeTokens', () => { + it('should work with plain theme', () => { + expect( + generateThemeTokens({ + colors: { + primary: 'red', + secondary: 'blue', + }, + fontSizes: [12, 14, 16, 20, 24, 32], + }), + ).to.deep.equal({}); + }); + + it('should use `vars` object', () => { + expect( + generateThemeTokens( + extendTheme({ + colorSchemes: { + light: { + palette: { + primary: 'red', + secondary: 'blue', + }, + }, + dark: { + palette: { + primary: 'darkred', + secondary: 'darkblue', + }, + }, + }, + radius: { + xs: 4, + sm: 8, + md: 16, + }, + }), + ), + ).to.deep.equal({ + vars: { + palette: { + primary: 'var(--palette-primary)', + secondary: 'var(--palette-secondary)', + }, + radius: { + md: 'var(--radius-md)', + sm: 'var(--radius-sm)', + xs: 'var(--radius-xs)', + }, + }, + }); + }); + }); +});