diff --git a/docs/data/joy/customization/dark-mode/DarkModeByDefault.js b/docs/data/joy/customization/dark-mode/DarkModeByDefault.js deleted file mode 100644 index e3ebdf7242bd74..00000000000000 --- a/docs/data/joy/customization/dark-mode/DarkModeByDefault.js +++ /dev/null @@ -1,43 +0,0 @@ -import * as React from 'react'; -import { CssVarsProvider, extendTheme } from '@mui/joy/styles'; -import Sheet from '@mui/joy/Sheet'; -import Chip from '@mui/joy/Chip'; -import Typography from '@mui/joy/Typography'; - -const theme = extendTheme({ cssVarPrefix: 'demo' }); - -export default function DarkModeByDefault() { - return ( - -
- - - Default - - } - sx={{ fontSize: 'lg' }} - > - Dark mode - - -
-
- ); -} diff --git a/docs/data/joy/customization/dark-mode/DarkModeByDefault.tsx b/docs/data/joy/customization/dark-mode/DarkModeByDefault.tsx deleted file mode 100644 index e3ebdf7242bd74..00000000000000 --- a/docs/data/joy/customization/dark-mode/DarkModeByDefault.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import * as React from 'react'; -import { CssVarsProvider, extendTheme } from '@mui/joy/styles'; -import Sheet from '@mui/joy/Sheet'; -import Chip from '@mui/joy/Chip'; -import Typography from '@mui/joy/Typography'; - -const theme = extendTheme({ cssVarPrefix: 'demo' }); - -export default function DarkModeByDefault() { - return ( - -
- - - Default - - } - sx={{ fontSize: 'lg' }} - > - Dark mode - - -
-
- ); -} diff --git a/docs/data/joy/customization/dark-mode/IdentifySystemMode.js b/docs/data/joy/customization/dark-mode/IdentifySystemMode.js index 85cd13873eacd8..0f48de99c277d1 100644 --- a/docs/data/joy/customization/dark-mode/IdentifySystemMode.js +++ b/docs/data/joy/customization/dark-mode/IdentifySystemMode.js @@ -39,7 +39,6 @@ function Identifier() { export default function IdentifySystemMode() { return ( setMode(mode === 'dark' ? 'light' : 'dark')} + value={mode} + onChange={(event, newMode) => { + setMode(newMode); + }} > - {mode === 'dark' ? 'Turn light' : 'Turn dark'} - + + + + ); } -export default function ModeToggle() { - // the `node` is used for attaching CSS variables to this demo, - // you might not need it in your application. - const [node, setNode] = React.useState(null); - useEnhancedEffect(() => { - setNode(document.getElementById('mode-toggle')); - }, []); +const theme = extendTheme({ + cssVarPrefix: 'mode-toggle', + colorSchemeSelector: '.demo_mode-toggle-%s', +}); +export default function ModeToggle() { return ( - - - + ); } diff --git a/docs/data/joy/customization/dark-mode/ModeToggle.tsx b/docs/data/joy/customization/dark-mode/ModeToggle.tsx index fd9b1c5e784ceb..12f95f68c9f359 100644 --- a/docs/data/joy/customization/dark-mode/ModeToggle.tsx +++ b/docs/data/joy/customization/dark-mode/ModeToggle.tsx @@ -1,10 +1,7 @@ import * as React from 'react'; -import { CssVarsProvider, useColorScheme } from '@mui/joy/styles'; -import Box from '@mui/joy/Box'; -import Button from '@mui/joy/Button'; - -const useEnhancedEffect = - typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect; +import { CssVarsProvider, useColorScheme, extendTheme } from '@mui/joy/styles'; +import Select from '@mui/joy/Select'; +import Option from '@mui/joy/Option'; function ModeSwitcher() { const { mode, setMode } = useColorScheme(); @@ -18,50 +15,40 @@ function ModeSwitcher() { return null; } return ( - + + + + ); } -export default function ModeToggle() { - // the `node` is used for attaching CSS variables to this demo, - // you might not need it in your application. - const [node, setNode] = React.useState(null); - useEnhancedEffect(() => { - setNode(document.getElementById('mode-toggle')); - }, []); +const theme = extendTheme({ + cssVarPrefix: 'mode-toggle', + colorSchemeSelector: '.demo_mode-toggle-%s', +}); +export default function ModeToggle() { return ( - - - + ); } diff --git a/docs/data/joy/customization/dark-mode/ModeToggle.tsx.preview b/docs/data/joy/customization/dark-mode/ModeToggle.tsx.preview new file mode 100644 index 00000000000000..4423e60cc8a579 --- /dev/null +++ b/docs/data/joy/customization/dark-mode/ModeToggle.tsx.preview @@ -0,0 +1,14 @@ + + + \ No newline at end of file diff --git a/docs/data/joy/customization/dark-mode/dark-mode.md b/docs/data/joy/customization/dark-mode/dark-mode.md index b03640976c71ab..c428e61c151e48 100644 --- a/docs/data/joy/customization/dark-mode/dark-mode.md +++ b/docs/data/joy/customization/dark-mode/dark-mode.md @@ -2,39 +2,23 @@

Learn about the different methods for applying dark mode to a Joy UI app.

-## Set as default +## Media prefers-color-scheme -To set dark mode as the default for your app, add `defaultMode: 'dark'` to your `` wrapper component: - -:::warning -When you change the `defaultMode` to another value, you must clear the local storage for it to take effect. -::: - -{{"demo": "DarkModeByDefault.js"}} - -For server-side applications, check out the framework setup in [the section below](#server-side-rendering) and provide the same value to the `InitColorSchemeScript` component: +Create a theme with `colorSchemeSelector: 'media'` to use `@media (prefers-color-scheme)` instead of the default `data-joy-color-scheme` attribute. ```js - -``` +import { extendTheme } from '@mui/joy/styles'; -## Matching device's preference - -Use `defaultMode: 'system'` to set your app's default mode to match the user's chosen preference on their device. - -```jsx -import { CssVarsProvider } from '@mui/joy/styles'; - -...; -``` +const theme = extendTheme({ + colorSchemeSelector: 'media', +}); -For server-side applications, check out the framework setup in [the section below](#server-side-rendering) and provide the same value to the `InitColorSchemeScript` component: - -```js - +function App() { + return ...; +} ``` -### Identify the system mode +## Identify the system mode Use the `useColorScheme` React hook to check if the user's preference is in light or dark mode: @@ -58,25 +42,7 @@ The `useColorScheme()` hook only works with components nested inside of ` setMode(mode === 'dark' ? 'light' : 'dark')} - > - {mode === 'dark' ? 'Turn light' : 'Turn dark'} - - ); -} -``` +In the example below, we're using a `Select` component that calls `setMode` from the `useColorSchemes()` hook to handle the mode switching. {{"demo": "ModeToggle.js"}} diff --git a/docs/data/joy/customization/theme-colors/BootstrapVariantTokens.js b/docs/data/joy/customization/theme-colors/BootstrapVariantTokens.js index ff9050cd92e05c..b600f18e71a321 100644 --- a/docs/data/joy/customization/theme-colors/BootstrapVariantTokens.js +++ b/docs/data/joy/customization/theme-colors/BootstrapVariantTokens.js @@ -116,11 +116,7 @@ export default function BootstrapVariantTokens() { }, []); return ( - + + + { - setMode(mode === 'light' ? 'dark' : 'light'); + value={mode} + onChange={(event, newMode) => { + setMode(newMode); }} + sx={{ width: 'max-content' }} > - {mode === 'light' ? 'Turn dark' : 'Turn light'} - + + + + ); } diff --git a/docs/data/joy/getting-started/tutorial/tutorial.md b/docs/data/joy/getting-started/tutorial/tutorial.md index ab132e415bd7a5..e8a5ff8d339d6c 100644 --- a/docs/data/joy/getting-started/tutorial/tutorial.md +++ b/docs/data/joy/getting-started/tutorial/tutorial.md @@ -188,9 +188,12 @@ Add `useColorScheme` to your import from `@mui/joy/styles`: import { CssVarsProvider, useColorScheme } from '@mui/joy/styles'; ``` -Next, create a light/dark mode toggle button by adding the following code snippet in between your imports and your `App()`: +Next, create a light/dark mode switcher by adding the following code snippet in between your imports and your `App()`: ```jsx +import Select from '@mui/joy/Select'; +import Option from '@mui/joy/Option'; + function ModeToggle() { const { mode, setMode } = useColorScheme(); const [mounted, setMounted] = React.useState(false); @@ -205,14 +208,17 @@ function ModeToggle() { } return ( - + + {/* other components */} + ``` -:::info -Using this utility is equivalent to writing a plain string `'[data-mui-color-scheme="dark"] &'` if you don't have a custom configuration. -::: - -## Force a specific color scheme - -Specify `data-mui-color-scheme="dark"` to any DOM node to force the children components to appear as if they are in dark mode. - -```js -
+```js data-attribute +// if the selector is '[data-mode-%s]' +
+ {/* other components */}
``` -## Dark color scheme application + + +## Disabling CSS color scheme -For an application that only has a dark mode, set the default mode to `dark`: +By default, the `extendTheme` attach [CSS color-scheme](https://developer.mozilla.org/en-US/docs/Web/CSS/color-scheme) based on the palette mode. If you want to disable it, set `disableCssColorScheme` to `true`: ```js -const theme = extendTheme({ - // ... +extendTheme({ + colorSchemes: { light: true, dark: true }, + disableCssColorScheme: true, }); +``` -// remove the `light` color scheme to optimize the HTML size for server-side application -delete theme.colorSchemes.light; +The generated CSS will not include the `color-scheme` property: -function App() { - return ( - - ... - - ); -} +```diff + @media (prefers-color-scheme: dark) { + :root { +- color-scheme: dark; + --mui-palette-primary-main: #90caf9; + ... + } + } ``` -For a server-side application, provide the same value to [`InitColorSchemeScript`](/material-ui/customization/css-theme-variables/usage/#server-side-rendering): +## Instant transition between color schemes + +To disable CSS transition when switching between modes, use `disableTransitionOnChange` prop: ```js - + ``` - -:::warning -In development, make sure to clear local storage and refresh the page after you configure the `defaultMode`. -::: diff --git a/docs/data/material/customization/css-theme-variables/overview.md b/docs/data/material/customization/css-theme-variables/overview.md index b706687d6cb99e..22b4b3a8bc23d3 100644 --- a/docs/data/material/customization/css-theme-variables/overview.md +++ b/docs/data/material/customization/css-theme-variables/overview.md @@ -112,7 +112,7 @@ extendTheme({ color: theme.vars.palette.primary.main, // When the mode switches to dark, the attribute selector is attached to // the tag by default. - '[data-mui-color-scheme="dark"] &': { + '*:where([data-mui-color-scheme="dark"]) &': { color: '#fff', }, }), diff --git a/docs/data/material/customization/css-theme-variables/usage.md b/docs/data/material/customization/css-theme-variables/usage.md new file mode 100644 index 00000000000000..54e15baeba694d --- /dev/null +++ b/docs/data/material/customization/css-theme-variables/usage.md @@ -0,0 +1,394 @@ +# CSS theme variables - Usage + +

Learn how to adopt CSS theme variables.

+ +## Getting started + +The CSS variables API relies on a provider called `CssVarsProvider` to inject styles into Material UI components. +`CssVarsProvider` generates CSS variables out of all tokens in the theme that are serializable, and makes them available in the React context along with the theme itself via [`ThemeProvider`](/material-ui/customization/theming/#theme-provider). + +Once the `App` renders on the screen, you will see the CSS theme variables in the HTML `:root` stylesheet. +The variables are flattened and prefixed with `--mui` by default: + + + +```jsx JSX +import { CssVarsProvider } from '@mui/material/styles'; + +function App() { + return {/* ...you app */}; +} +``` + +```css CSS +:root { + --mui-palette-primary-main: #1976d2; + --mui-palette-primary-light: #42a5f5; + --mui-palette-primary-dark: #1565c0; + --mui-palette-primary-contrastText: #fff; + /* ...other variables */ +} +``` + + + +:::info +The `CssVarsProvider` is built on top of the [`ThemeProvider`](/material-ui/customization/theming/#themeprovider) with extra features like CSS variable generation, storage synchronization, unlimited color schemes, and more. + +If you have an existing theme, you can migrate to CSS theme variables by following the [migration guide](/material-ui/migration/migration-css-theme-variables/). +::: + +## Dark mode only application + +To switch the default light to dark palette, set `colorSchemes: { dark: true }` to the `extendTheme`. +Material UI will generate the dark palette instead. + +```jsx +import { CssVarsProvider, extendTheme } from '@mui/material/styles'; + +const theme = extendTheme({ + colorSchemes: { dark: true }, +}); + +function App() { + return {/* ...you app */}; +} +``` + +## Light and dark mode application + +To support both light and dark modes, set `colorSchemes: { light: true, dark: true }` to the `extendTheme`. +Material UI will generate both light and dark palette with [`@media (prefers-color-scheme)`](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme). + + + +```jsx JSX +import { CssVarsProvider, extendTheme } from '@mui/material/styles'; + +const theme = extendTheme({ + colorSchemes: { light: true, dark: true }, +}); + +function App() { + return {/* ...you app */}; +} +``` + +```css CSS +/* generated global stylesheet */ + +:root { + --mui-palette-primary-main: #1976d2; + /* ...other variables */ +} + +@media (prefers-color-scheme: dark) { + :root { + --mui-palette-primary-main: #90caf9; + /* ...other variables */ + } +} +``` + + + +If you want to manually toggle the color scheme, check out the [advanced configuration](/material-ui/customization/css-theme-variables/configuration/#advanced-configuration). + +## Applying dark styles + +To customize styles for dark mode, use `theme.applyStyles` function. +This utility function will return the right selector. + +The example below shows how to customize the Card component for dark mode: + +```js +import Card from '@mui/material/Card'; + + ({ + backgroundColor: theme.vars.palette.background.default, + ...theme.applyStyles('dark', { + boxShadow: 'none', // remove the box shadow in dark mode + }), + })} +/>; +``` + +## Using theme variables + +All of these variables are accessible in an object in the theme called `vars`. +The structure of this object is a serializable theme structure with the values represent CSS variables. + +- `theme.vars` (recommended): an object that refers to the CSS theme variables. + + ```js + const Button = styled('button')(({ theme }) => ({ + backgroundColor: theme.vars.palette.primary.main, // var(--mui-palette-primary-main) + color: theme.vars.palette.primary.contrastText, // var(--mui-palette-primary-contrastText) + })); + ``` + + For **TypeScript**, the typings are not enabled by default. + Follow the [TypeScript setup](#typescript) to enable the typings. + + :::success + If the components need to render outside of the `CssVarsProvider`, add fallback to the theme object. + + ```js + backgroundColor: (theme.vars || theme).palette.primary.main; + ``` + + ::: + +- **Native CSS**: if you can't access the theme object, for example in a pure CSS file, you can use [`var()`](https://developer.mozilla.org/en-US/docs/Web/CSS/var) directly: + + ```css + /* external-scope.css */ + .external-section { + background-color: var(--mui-palette-grey-50); + } + ``` + +## Theming + +:::warning +`extendTheme` is not the same as [`createTheme`](/material-ui/customization/theming/#createtheme-options-args-theme). +Do not use them interchangeably. + +- `createTheme()` returns a theme for `ThemeProvider`. +- `extendTheme()` returns a theme for `CssVarsProvider`. + +::: + +The major difference from the [`createTheme`](/material-ui/customization/theming/#createtheme-options-args-theme) approach is in palette customization. +With the `extendTheme` API, you can specify the palette for `light` and `dark` color schemes at once. The rest of the theme structure remains the same. + +Here are examples of customizing each part of the theme: + + + +```js color-schemes +import { pink } from '@mui/material/colors'; + +extendTheme({ + colorSchemes: { + light: { + palette: { + primary: { + main: pink[600], + }, + }, + }, + dark: { + palette: { + primary: { + main: pink[400], + }, + }, + }, + }, +}); +``` + +```js typography +extendTheme({ + typography: { + fontFamily: '"Inter", "sans-serif"', + h1: { + fontSize: customTheme.typography.pxToRem(60), + fontWeight: 600, + lineHeight: 1.2, + letterSpacing: -0.5, + }, + h2: { + fontSize: customTheme.typography.pxToRem(48), + fontWeight: 600, + lineHeight: 1.2, + }, + }, +}); +``` + +```js spacing +extendTheme({ + spacing: '0.5rem', +}); +``` + +```js shape +extendTheme({ + shape: { + borderRadius: 12, + }, +}); +``` + +```js components +extendTheme({ + components: { + MuiChip: { + styleOverrides: { + root: ({ theme }) => ({ + variants: [ + { + props: { variant: 'outlined', color: 'primary' }, + style: { + backgroundColor: theme.vars.palette.background.paper, + }, + }, + ], + }), + }, + }, + }, +}); +``` + + + +### Channel tokens + +A channel token is used for creating translucent color. It is a variable that consists of [color space channels](https://www.w3.org/TR/css-color-4/#color-syntax) but without the alpha component. The value of a channel token is separated by a space, for example `12 223 31`, which can be combined with the [color functions](https://www.w3.org/TR/css-color-4/#color-functions) to create a translucent color. + +The `extendTheme()` automatically generates channel tokens that are likely to be used frequently from the theme palette. Those colors are suffixed with `Channel`, for example: + +```js +const theme = extendTheme(); +const light = theme.colorSchemes.light; + +console.log(light.palette.primary.mainChannel); // '25 118 210' +// This token is generated from `theme.colorSchemes.light.palette.primary.main`. +``` + +You can use the channel tokens to create a translucent color like this: + +```js +const theme = extendTheme({ + components: { + MuiChip: { + styleOverrides: { + root: ({ theme }) => ({ + variants: [ + { + props: { variant: 'outlined', color: 'primary' }, + style: { + backgroundColor: `rgba(${theme.vars.palette.primary.mainChannel} / 0.12)`, + }, + }, + ], + }), + }, + }, + }, +}); +``` + +:::warning +Don't use a comma (`,`) as a separator because the channel colors use empty spaces to define [transparency](https://www.w3.org/TR/css-color-4/#transparency): + +```js +`rgba(${theme.vars.palette.primary.mainChannel}, 0.12)`, // 🚫 this does not work +`rgba(${theme.vars.palette.primary.mainChannel} / 0.12)`, // ✅ always use `/` +``` + +::: + +### Adding new theme tokens + +You can add other key-value pairs to the theme input which will be generated as a part of the CSS theme variables: + +```js +const theme = extendTheme({ + colorSchemes: { + light: { + palette: { + // The best part is that you can refer to the variables wherever you like 🤩 + gradient: + 'linear-gradient(to left, var(--mui-palette-primary-main), var(--mui-palette-primary-dark))', + border: { + subtle: 'var(--mui-palette-neutral-200)', + }, + }, + }, + dark: { + palette: { + gradient: + 'linear-gradient(to left, var(--mui-palette-primary-light), var(--mui-palette-primary-main))', + border: { + subtle: 'var(--mui-palette-neutral-600)', + }, + }, + }, + }, +}); + +function App() { + return ...; +} +``` + +Then, you can access those variables from the `theme.vars` object: + +```js +const Divider = styled('hr')(({ theme }) => ({ + height: 1, + border: '1px solid', + borderColor: theme.vars.palette.border.subtle, + backgroundColor: theme.vars.palette.gradient, +})); +``` + +Or use `var()` to refer to the CSS variable directly: + +```css +/* global.css */ +.external-section { + background-color: var(--mui-palette-gradient); +} +``` + +:::warning +If you're using a [custom prefix](/material-ui/customization/css-theme-variables/configuration/#changing-variable-prefixes), make sure to replace the default `--mui`. +::: + +For **TypeScript**, you need to augment the [palette interfaces](#palette-interfaces). + +## TypeScript + +The theme variables type is not enabled by default. You need to import the module augmentation to enable the typings: + +```ts +// The import can be in any file that is included in your `tsconfig.json` +import type {} from '@mui/material/themeCssVarsAugmentation'; +import { styled } from '@mui/material/styles'; + +const StyledComponent = styled('button')(({ theme }) => ({ + // ✅ typed-safe + color: theme.vars.palette.primary.main, +})); +``` + +### Palette interfaces + +To add new tokens to the theme palette, you need to augment the `PaletteOptions` and `Palette` interfaces: + +```ts +declare module '@mui/material/styles' { + interface PaletteOptions { + gradient: string; + border: { + subtle: string; + }; + } + interface Palette { + gradient: string; + border: { + subtle: string; + }; + } +} +``` + +## Next steps + +If you need to support system preference and manual selection, check out the [advanced configuration](/material-ui/customization/css-theme-variables/configuration/) diff --git a/docs/data/material/customization/css-theme-variables/usage/CssVarsBasic.js b/docs/data/material/customization/css-theme-variables/usage/CssVarsBasic.js deleted file mode 100644 index db146ef6c72468..00000000000000 --- a/docs/data/material/customization/css-theme-variables/usage/CssVarsBasic.js +++ /dev/null @@ -1,15 +0,0 @@ -import * as React from 'react'; -import { extendTheme, CssVarsProvider } from '@mui/material/styles'; -import Button from '@mui/material/Button'; - -const theme = extendTheme({ - cssVarPrefix: 'md-demo', -}); - -export default function CssVarsBasic() { - return ( - - - - ); -} diff --git a/docs/data/material/customization/css-theme-variables/usage/CssVarsBasic.tsx b/docs/data/material/customization/css-theme-variables/usage/CssVarsBasic.tsx deleted file mode 100644 index db146ef6c72468..00000000000000 --- a/docs/data/material/customization/css-theme-variables/usage/CssVarsBasic.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import * as React from 'react'; -import { extendTheme, CssVarsProvider } from '@mui/material/styles'; -import Button from '@mui/material/Button'; - -const theme = extendTheme({ - cssVarPrefix: 'md-demo', -}); - -export default function CssVarsBasic() { - return ( - - - - ); -} diff --git a/docs/data/material/customization/css-theme-variables/usage/CssVarsBasic.tsx.preview b/docs/data/material/customization/css-theme-variables/usage/CssVarsBasic.tsx.preview deleted file mode 100644 index 511e5c2381dd78..00000000000000 --- a/docs/data/material/customization/css-theme-variables/usage/CssVarsBasic.tsx.preview +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/docs/data/material/customization/css-theme-variables/usage/usage.md b/docs/data/material/customization/css-theme-variables/usage/usage.md deleted file mode 100644 index 0f26739d4fed9b..00000000000000 --- a/docs/data/material/customization/css-theme-variables/usage/usage.md +++ /dev/null @@ -1,198 +0,0 @@ -# CSS theme variables - Usage - -

Learn how to adopt CSS theme variables.

- -## Getting started - -The CSS variables API relies on a provider called `CssVarsProvider` to inject styles into Material UI components. -`CssVarsProvider` generates CSS variables out of all tokens in the theme that are serializable, and makes them available in the React context along with the theme itself via [`ThemeProvider`](/material-ui/customization/theming/#theme-provider). - -Once the `App` renders on the screen, you will see the CSS theme variables in the HTML `:root` stylesheet. -The variables are flattened and prefixed with `--mui` by default: - -```css -/* generated global stylesheet */ -:root { - --mui-palette-primary-main: #1976d2; - --mui-palette-primary-light: #42a5f5; - --mui-palette-primary-dark: #1565c0; - --mui-palette-primary-contrastText: #fff; - /* ...other variables */ -} -``` - -The following demo uses `--md-demo` as a prefix for the variables: - -{{"demo": "CssVarsBasic.js", "defaultCodeOpen": true}} - -:::info -The `CssVarsProvider` is built on top of the [`ThemeProvider`](/material-ui/customization/theming/#themeprovider) with extra features like CSS variable generation, storage synchronization, unlimited color schemes, and more. - -If you have an existing theme, you can migrate to CSS theme variables by following the [migration guide](/material-ui/migration/migration-css-theme-variables/). -::: - -## Toggle between light and dark mode - -The `useColorScheme` hook lets you read and update the user-selected mode: - -```jsx -import { CssVarsProvider, useColorScheme } from '@mui/material/styles'; - -// ModeSwitcher is an example interface for toggling between modes. -// Material UI does not provide the toggle interface—you have to build it yourself. -const ModeSwitcher = () => { - const { mode, setMode } = useColorScheme(); - const [mounted, setMounted] = React.useState(false); - - React.useEffect(() => { - setMounted(true); - }, []); - - if (!mounted) { - // for server-side rendering - // learn more at https://github.com/pacocoursey/next-themes#avoid-hydration-mismatch - return null; - } - - return ( - - ); -}; - -function App() { - return ( - - - - ); -} -``` - -## Using theme variables - -All of these variables are accessible in an object in the theme called `vars`. -The structure of this object is nearly identical to the theme structure, the only difference is that the values represent CSS variables. - -- `theme.vars` (recommended): an object that refers to the CSS theme variables. - - ```js - const Button = styled('button')(({ theme }) => ({ - backgroundColor: theme.vars.palette.primary.main, // var(--mui-palette-primary-main) - color: theme.vars.palette.primary.contrastText, // var(--mui-palette-primary-contrastText) - })); - ``` - - For **TypeScript**, the typings are not enabled by default. - Follow the [TypeScript setup](#typescript) to enable the typings. - - :::warning - Make sure that the components accessing `theme.vars.*` are rendered under the new provider, otherwise you will get a `TypeError`. - ::: - -- **Native CSS**: if you can't access the theme object, for example in a pure CSS file, you can use [`var()`](https://developer.mozilla.org/en-US/docs/Web/CSS/var) directly: - - ```css - /* external-scope.css */ - .external-section { - background-color: var(--mui-palette-grey-50); - } - ``` - - :::warning - If you have set up a [custom prefix](/material-ui/customization/css-theme-variables/configuration/#changing-variable-prefixes), make sure to replace the default `--mui`. - ::: - -## Server-side rendering - -Place `` before the `
` tag to prevent the dark-mode SSR flickering during the hydration phase. - -### Next.js App Router - -Add the following code to the [root layout](https://nextjs.org/docs/app/building-your-application/routing/pages-and-layouts#root-layout-required) file: - -```jsx title="app/layout.js" -import InitColorSchemeScript from '@mui/material/InitColorSchemeScript'; - -export default function RootLayout({ children }) { - return ( - - - {/* must come before the
element */} -
{children}
- - - ); -} -``` - -### Next.js Pages Router - -Add the following code to the custom [`pages/_document.js`](https://nextjs.org/docs/pages/building-your-application/routing/custom-document) file: - -```jsx title="pages/_document.js" -import Document, { Html, Head, Main, NextScript } from 'next/document'; -import InitColorSchemeScript from '@mui/material/InitColorSchemeScript'; - -export default class MyDocument extends Document { - render() { - return ( - - ... - - {/* must come before the
element */} -
- - - - ); - } -} -``` - -## TypeScript - -The theme variables type is not enabled by default. You need to import the module augmentation to enable the typings: - -```ts -// The import can be in any file that is included in your `tsconfig.json` -import type {} from '@mui/material/themeCssVarsAugmentation'; -import { styled } from '@mui/material/styles'; - -const StyledComponent = styled('button')(({ theme }) => ({ - // ✅ typed-safe - color: theme.vars.palette.primary.main, -})); -``` - -## API - -### ``  props - -- `defaultMode?: 'light' | 'dark' | 'system'` - Application's default mode (`light` by default) -- `disableTransitionOnChange : boolean` - Disable CSS transitions when switching between modes -- `theme: ThemeInput` - the theme provided to React's context -- `modeStorageKey?: string` - localStorage key used to store application `mode` -- `attribute?: string` - DOM attribute for applying color scheme - -### `useColorScheme: () => ColorSchemeContextValue` - -- `mode: string` - The user's selected mode -- `setMode: mode => {…}` - Function for setting the `mode`. The `mode` is saved to internal state and local storage; if `mode` is null, it will be reset to the default mode - -### ``  props - -- `defaultMode?: 'light' | 'dark' | 'system'`: - Application's default mode before React renders the tree (`light` by default) -- `modeStorageKey?: string`: - localStorage key used to store application `mode` -- `attribute?: string` - DOM attribute for applying color scheme -- `nonce?: string` - Optional nonce passed to the injected script tag, used to allow-list the next-themes script in your CSP diff --git a/docs/data/material/pages.ts b/docs/data/material/pages.ts index 907d10ee47cfac..3df0800866aa33 100644 --- a/docs/data/material/pages.ts +++ b/docs/data/material/pages.ts @@ -196,11 +196,16 @@ const pages: MuiPage[] = [ { pathname: '/material-ui/customization/css-variables', subheader: '/material-ui/customization/css-variables', + newFeature: true, children: [ { pathname: '/material-ui/customization/css-theme-variables/overview' }, - { pathname: '/material-ui/customization/css-theme-variables/usage' }, + { + pathname: '/material-ui/customization/css-theme-variables/usage', + title: 'Basic usage', + }, { pathname: '/material-ui/customization/css-theme-variables/configuration', + title: 'Advanced configuration', }, ], }, 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 e61144ab3e3e5e..d535a70d7259dd 100644 --- a/docs/data/system/experimental-api/css-theme-variables/CreateCssVarsProvider.js +++ b/docs/data/system/experimental-api/css-theme-variables/CreateCssVarsProvider.js @@ -35,6 +35,7 @@ const darkColorScheme = { }; function extendTheme({ cssVarPrefix = 'system-demo' } = {}) { + const colorSchemeSelector = 'data-system-demo-color-scheme'; const { vars: themeVars, ...params } = prepareCssVars( { colorSchemes: { @@ -44,9 +45,11 @@ function extendTheme({ cssVarPrefix = 'system-demo' } = {}) { }, { prefix: cssVarPrefix, + colorSchemeSelector, }, ); const theme = { + colorSchemeSelector, colorSchemes: { light: lightColorScheme, dark: darkColorScheme, @@ -69,7 +72,6 @@ const myCustomDefaultTheme = extendTheme(); const { CssVarsProvider, useColorScheme } = createCssVarsProvider({ theme: myCustomDefaultTheme, modeStorageKey: 'system-demo-mode', - attribute: 'data-system-demo-color-scheme', defaultColorScheme: { light: 'light', dark: 'dark', 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 88848fbd071e25..ba3a45b1fc2880 100644 --- a/docs/data/system/experimental-api/css-theme-variables/CreateCssVarsProvider.tsx +++ b/docs/data/system/experimental-api/css-theme-variables/CreateCssVarsProvider.tsx @@ -45,6 +45,7 @@ const darkColorScheme = { }; function extendTheme({ cssVarPrefix = 'system-demo' } = {}) { + const colorSchemeSelector = 'data-system-demo-color-scheme'; const { vars: themeVars, ...params } = prepareCssVars( { colorSchemes: { @@ -54,9 +55,11 @@ function extendTheme({ cssVarPrefix = 'system-demo' } = {}) { }, { prefix: cssVarPrefix, + colorSchemeSelector, }, ); - const theme: Theme = { + const theme: Theme & { colorSchemeSelector: string } = { + colorSchemeSelector, colorSchemes: { light: lightColorScheme, dark: darkColorScheme, @@ -79,7 +82,6 @@ const myCustomDefaultTheme = extendTheme(); const { CssVarsProvider, useColorScheme } = createCssVarsProvider({ theme: myCustomDefaultTheme, modeStorageKey: 'system-demo-mode', - attribute: 'data-system-demo-color-scheme', defaultColorScheme: { light: 'light', dark: 'dark', diff --git a/docs/data/system/experimental-api/css-theme-variables/css-theme-variables.md b/docs/data/system/experimental-api/css-theme-variables/css-theme-variables.md index 9b834cccf0687e..117d1727b44107 100644 --- a/docs/data/system/experimental-api/css-theme-variables/css-theme-variables.md +++ b/docs/data/system/experimental-api/css-theme-variables/css-theme-variables.md @@ -194,7 +194,6 @@ See the complete usage of `createCssVarsProvider` in [Material UI](https://gith ### `createCssVarsProvider` options -- `attribute?`: DOM attribute for applying color scheme (`data-color-scheme` by default) - `modeStorageKey?`: localStorage key used to store application `mode` (`mode` by default) - `colorSchemeStorageKey?`: localStorage key used to store `colorScheme` - `defaultColorScheme`: Design system default color scheme (string or object depending on if the design system has 1 or more themes, can be `light` or `dark`) @@ -210,9 +209,12 @@ See the complete usage of `createCssVarsProvider` in [Material UI](https://gith - `defaultMode?: 'light' | 'dark' | 'system'` - Application's default mode (`light` by default) - `disableTransitionOnChange : boolean` - Disable CSS transitions when switching between modes -- `theme: ThemeInput` - the theme provided to React's context +- `theme: ThemeInput` - The theme provided to React's context. It should have these fields: + - `colorSchemes: { [key: string]: ColorScheme }` - The color schemes for the application + - `colorSchemeSelector: 'media' | 'class' | 'data' | string`: - The method to apply CSS theme variables and component styles + - `generateStyleSheets: () => Record` - Function to generate CSS variables + - `generateThemeVars: () => Record` - Function to generate CSS variables reference for the `theme.vars` - `modeStorageKey?: string` - localStorage key used to store application `mode` -- `attribute?: string` - DOM attribute for applying color scheme ### `useColorScheme: () => ColorSchemeContextValue` diff --git a/docs/pages/experiments/joy/style-guide.tsx b/docs/pages/experiments/joy/style-guide.tsx index 77619e74526b43..51c136036411c7 100644 --- a/docs/pages/experiments/joy/style-guide.tsx +++ b/docs/pages/experiments/joy/style-guide.tsx @@ -154,7 +154,7 @@ function TypographyScale() { export default function JoyStyleGuide() { return ( - + ; diff --git a/docs/public/static/error-codes.json b/docs/public/static/error-codes.json index c5acc584689575..87ced849443515 100644 --- a/docs/public/static/error-codes.json +++ b/docs/public/static/error-codes.json @@ -19,5 +19,7 @@ "18": "MUI: `vars` is a private field used for CSS variables support.\nPlease use another name.", "19": "MUI: `useColorScheme` must be called under ", "20": "MUI: The `experimental_sx` has been moved to `theme.unstable_sx`.For more details, see https://github.com/mui/material-ui/pull/35150.", - "21": "MUI: The provided shorthand %s is invalid. The format should be `@` or `@/`.\nFor example, `@sm` or `@600` or `@40rem/sidebar`." + "21": "MUI: The provided shorthand %s is invalid. The format should be `@` or `@/`.\nFor example, `@sm` or `@600` or `@40rem/sidebar`.", + "22": "MUI: Missing or invalid value of `colorSchemes.%s` from the `extendTheme` function.", + "23": "MUI: The provided `colorSchemes.%s` to the `extendTheme` function is either missing or invalid." } diff --git a/docs/src/BrandingCssVarsProvider.tsx b/docs/src/BrandingCssVarsProvider.tsx index 06b7a6197a67fc..26b0e225af71a9 100644 --- a/docs/src/BrandingCssVarsProvider.tsx +++ b/docs/src/BrandingCssVarsProvider.tsx @@ -18,6 +18,7 @@ const { palette: darkPalette } = getDesignTokens('dark'); const theme = extendTheme({ cssVarPrefix: 'muidocs', + colorSchemeSelector: 'data-mui-color-scheme', colorSchemes: { light: { palette: lightPalette, @@ -50,7 +51,7 @@ const theme = extendTheme({ export default function BrandingCssVarsProvider(props: { children: React.ReactNode }) { const { children } = props; return ( - + diff --git a/docs/src/components/home/MaterialDesignComponents.tsx b/docs/src/components/home/MaterialDesignComponents.tsx index 697c17f523f5f7..e65a553dd8e2b9 100644 --- a/docs/src/components/home/MaterialDesignComponents.tsx +++ b/docs/src/components/home/MaterialDesignComponents.tsx @@ -516,8 +516,13 @@ export function buildTheme(): ThemeOptions { const { palette: lightPalette, typography, ...designTokens } = getDesignTokens('light'); const { palette: darkPalette } = getDesignTokens('dark'); +const defaultTheme = extendTheme({ + colorSchemes: { light: true, dark: true }, + colorSchemeSelector: 'data-mui-color-scheme', +}); export const customTheme = extendTheme({ cssVarPrefix: 'muidocs', + colorSchemeSelector: 'data-mui-color-scheme', colorSchemes: { light: { palette: lightPalette, @@ -533,7 +538,7 @@ export const customTheme = extendTheme({ export default function MaterialDesignComponents() { const [anchor, setAnchor] = React.useState(null); const [customized, setCustomized] = React.useState(false); - const theme = customized ? customTheme : undefined; + const theme = customized ? customTheme : defaultTheme; return (
diff --git a/docs/src/components/productMaterial/MaterialHero.tsx b/docs/src/components/productMaterial/MaterialHero.tsx index d3c0468da9a9db..82a5d00b28df2f 100644 --- a/docs/src/components/productMaterial/MaterialHero.tsx +++ b/docs/src/components/productMaterial/MaterialHero.tsx @@ -197,6 +197,7 @@ const { palette: lightPalette } = getDesignTokens('light'); const { palette: darkPalette } = getDesignTokens('dark'); const customTheme = extendTheme({ cssVarPrefix: 'hero', + colorSchemeSelector: 'data-mui-color-scheme', colorSchemes: { light: { palette: { diff --git a/docs/translations/translations.json b/docs/translations/translations.json index f1dd6c1724591a..1ee4ef05be344a 100644 --- a/docs/translations/translations.json +++ b/docs/translations/translations.json @@ -239,8 +239,8 @@ "/material-ui/customization/transitions": "Transitions", "/material-ui/customization/css-variables": "Css variables", "/material-ui/customization/css-theme-variables/overview": "Overview", - "/material-ui/customization/css-theme-variables/usage": "Usage", - "/material-ui/customization/css-theme-variables/configuration": "Configuration", + "/material-ui/customization/css-theme-variables/usage": "Basic usage", + "/material-ui/customization/css-theme-variables/configuration": "Advanced configuration", "/material-ui/guides": "How-to guides", "/material-ui/guides/minimizing-bundle-size": "Minimizing bundle size", "/material-ui/guides/server-rendering": "Server rendering", diff --git a/packages/mui-joy/src/styles/defaultTheme.test.js b/packages/mui-joy/src/styles/defaultTheme.test.js index c595c01b9c6b91..23830e85b241e3 100644 --- a/packages/mui-joy/src/styles/defaultTheme.test.js +++ b/packages/mui-joy/src/styles/defaultTheme.test.js @@ -5,7 +5,6 @@ describe('defaultTheme', () => { it('the output contains required fields', () => { Object.keys(defaultTheme).forEach((field) => { expect([ - 'attribute', 'colorSchemeSelector', 'defaultColorScheme', 'breakpoints', diff --git a/packages/mui-joy/src/styles/extendTheme.test.js b/packages/mui-joy/src/styles/extendTheme.test.js index 29a7da8c536391..a3e6728ad1a44d 100644 --- a/packages/mui-joy/src/styles/extendTheme.test.js +++ b/packages/mui-joy/src/styles/extendTheme.test.js @@ -280,6 +280,9 @@ describe('extendTheme', () => { it('applyStyles', () => { const attribute = 'data-custom-color-scheme'; + const customTheme2 = extendTheme({ + colorSchemeSelector: attribute, + }); let darkStyles = {}; const Test = styled('div')(({ theme }) => { darkStyles = theme.applyStyles('dark', { @@ -289,7 +292,7 @@ describe('extendTheme', () => { }); render( - + , ); diff --git a/packages/mui-joy/src/styles/extendTheme.ts b/packages/mui-joy/src/styles/extendTheme.ts index 34052c61f99992..2986ecff1d4254 100644 --- a/packages/mui-joy/src/styles/extendTheme.ts +++ b/packages/mui-joy/src/styles/extendTheme.ts @@ -12,7 +12,7 @@ import { } from '@mui/system'; import cssContainerQueries from '@mui/system/cssContainerQueries'; import { unstable_applyStyles as applyStyles } from '@mui/system/createTheme'; -import { prepareTypographyVars } from '@mui/system/cssVars'; +import { prepareTypographyVars, createGetColorSchemeSelector } from '@mui/system/cssVars'; import { createUnarySpacing } from '@mui/system/spacing'; import defaultSxConfig from './sxConfig'; import colors from '../colors'; @@ -83,6 +83,23 @@ export interface CssVarsThemeOptions extends Partial2Level { * // { ..., typography: { body1: { fontSize: 'var(--fontSize-md)' } }, ... } */ cssVarPrefix?: string; + /** + * The strategy to generate CSS variables + * + * @example 'media' + * Generate CSS variables using [prefers-color-scheme](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme) + * + * @example '.mode-%s' + * Generate CSS variables within a class .mode-light, .mode-dark + * + * @example '[data-mode-%s]' + * Generate CSS variables within a data attribute [data-mode-light], [data-mode-dark] + */ + colorSchemeSelector?: 'media' | 'class' | 'data' | string; + /** + * @default 'light' + */ + defaultColorScheme?: DefaultColorScheme | ExtendedColorScheme; direction?: 'ltr' | 'rtl'; focus?: Partial; typography?: Partial; @@ -100,26 +117,6 @@ 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') => @@ -133,7 +130,7 @@ export default function extendTheme(themeOptions?: CssVarsThemeOptions): Theme { components: componentsInput, variants: variantsInput, shouldSkipGeneratingVar = defaultShouldSkipGeneratingVar, - getSelector, + colorSchemeSelector = 'data-joy-color-scheme', ...scalesInput } = themeOptions || {}; const getCssVar = createGetCssVar(cssVarPrefix); @@ -563,6 +560,7 @@ export default function extendTheme(themeOptions?: CssVarsThemeOptions): Theme { : defaultScales; let theme = { + colorSchemeSelector, colorSchemes, defaultColorScheme: 'light', ...mergedScales, @@ -608,7 +606,7 @@ export default function extendTheme(themeOptions?: CssVarsThemeOptions): Theme { getCssVar, spacing: getSpacingVal(spacing), font: { ...prepareTypographyVars(mergedScales.typography), ...mergedScales.font }, - } as unknown as Theme & { attribute: string; colorSchemeSelector: string }; // Need type casting due to module augmentation inside the repo + } as unknown as Theme & { colorSchemeSelector: string }; // Need type casting due to module augmentation inside the repo theme = cssContainerQueries(theme); /** @@ -651,28 +649,17 @@ export default function extendTheme(themeOptions?: CssVarsThemeOptions): Theme { // =============================================================== // Create `theme.vars` that contain `var(--*)` as values // =============================================================== - const parserConfig = { + const parserConfig: Parameters>[1] = { prefix: cssVarPrefix, + colorSchemeSelector, + disableCssColorScheme: true, 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, generateThemeVars, generateStyleSheets } = prepareCssVars( theme, parserConfig, ); - theme.attribute = 'data-joy-color-scheme'; - theme.colorSchemeSelector = ':root'; theme.vars = vars; theme.generateThemeVars = generateThemeVars; theme.generateStyleSheets = generateStyleSheets; @@ -691,8 +678,7 @@ export default function extendTheme(themeOptions?: CssVarsThemeOptions): Theme { theme: this, }); }; - theme.getColorSchemeSelector = (colorScheme: SupportedColorScheme) => - `[${theme.attribute}="${colorScheme}"] &`; + theme.getColorSchemeSelector = createGetColorSchemeSelector(colorSchemeSelector); const createVariantInput = { getCssVar, palette: theme.colorSchemes.light.palette }; theme.variants = deepmerge( diff --git a/packages/mui-joy/src/styles/shouldSkipGeneratingVar.ts b/packages/mui-joy/src/styles/shouldSkipGeneratingVar.ts index d61b8f695cc5b8..2f1e39c95ad0aa 100644 --- a/packages/mui-joy/src/styles/shouldSkipGeneratingVar.ts +++ b/packages/mui-joy/src/styles/shouldSkipGeneratingVar.ts @@ -1,6 +1,6 @@ export default function shouldSkipGeneratingVar(keys: string[]) { return ( - !!keys[0].match(/^(typography|variants|breakpoints)$/) || + !!keys[0].match(/^(colorSchemeSelector|typography|variants|breakpoints)$/) || !!keys[0].match(/sxConfig$/) || // ends with sxConfig (keys[0] === 'palette' && !!keys[1]?.match(/^(mode)$/)) || (keys[0] === 'focus' && keys[1] !== 'thickness') diff --git a/packages/mui-material/src/Paper/Paper.js b/packages/mui-material/src/Paper/Paper.js index abb07e7966bed5..3b42dce0db18dd 100644 --- a/packages/mui-material/src/Paper/Paper.js +++ b/packages/mui-material/src/Paper/Paper.js @@ -115,7 +115,7 @@ const Paper = React.forwardRef(function Paper(inProps, ref) { ...(variant === 'elevation' && { '--Paper-shadow': (theme.vars || theme).shadows[elevation], ...(theme.vars && { - '--Paper-overlay': theme.overlays?.[elevation], + '--Paper-overlay': theme.vars.overlays?.[elevation], }), ...(!theme.vars && theme.palette.mode === 'dark' && { diff --git a/packages/mui-material/src/styles/CssVarsProvider.test.js b/packages/mui-material/src/styles/CssVarsProvider.test.js index e77b138bf4e680..6dfd78746c7213 100644 --- a/packages/mui-material/src/styles/CssVarsProvider.test.js +++ b/packages/mui-material/src/styles/CssVarsProvider.test.js @@ -2,7 +2,7 @@ import * as React from 'react'; import { expect } from 'chai'; import { createRenderer, screen } from '@mui/internal-test-utils'; import Box from '@mui/material/Box'; -import { CssVarsProvider, useTheme } from '@mui/material/styles'; +import { CssVarsProvider, extendTheme, useTheme } from '@mui/material/styles'; describe('[Material UI] CssVarsProvider', () => { let originalMatchmedia; @@ -57,7 +57,7 @@ describe('[Material UI] CssVarsProvider', () => { } render( - + , ); @@ -328,7 +328,7 @@ describe('[Material UI] CssVarsProvider', () => { } const { getByTestId } = render( - + ({ themeId: THEME_ID, theme: defaultTheme, - attribute: defaultConfig.attribute, colorSchemeStorageKey: defaultConfig.colorSchemeStorageKey, modeStorageKey: defaultConfig.modeStorageKey, defaultColorScheme: { diff --git a/packages/mui-material/src/styles/createGetSelector.ts b/packages/mui-material/src/styles/createGetSelector.ts index b5eff828009ad9..2f79dc98c99cab 100644 --- a/packages/mui-material/src/styles/createGetSelector.ts +++ b/packages/mui-material/src/styles/createGetSelector.ts @@ -2,8 +2,7 @@ import excludeVariablesFromRoot from './excludeVariablesFromRoot'; export default < T extends { - attribute: string; - colorSchemeSelector: string; + colorSchemeSelector?: 'media' | 'class' | 'data' | string; colorSchemes?: Record; defaultColorScheme?: string; cssVarPrefix?: string; @@ -12,6 +11,18 @@ export default < theme: T, ) => (colorScheme: keyof T['colorSchemes'] | undefined, css: Record) => { + const selector = theme.colorSchemeSelector; + let rule = selector; + if (selector === 'class') { + rule = '.%s'; + } + if (selector === 'data') { + rule = '[data-%s]'; + } + if (selector?.startsWith('data-') && !selector.includes('%s')) { + // 'data-mui-color-scheme' -> '[data-mui-color-scheme="%s"]' + rule = `[${selector}="%s"]`; + } if (theme.defaultColorScheme === colorScheme) { if (colorScheme === 'dark') { const excludedVariables: typeof css = {}; @@ -19,15 +30,27 @@ export default < excludedVariables[cssVar] = css[cssVar]; delete css[cssVar]; }); - return { - [`[${theme.attribute}="${String(colorScheme)}"]`]: excludedVariables, - [theme.colorSchemeSelector!]: css, - }; + if (rule === 'media') { + return { + ':root': css, + '@media (prefers-color-scheme: dark) { :root': excludedVariables, + }; + } + if (rule) { + return { + [rule.replace('%s', colorScheme)]: excludedVariables, + ':root': css, + }; + } + return { ':root': { ...css, ...excludedVariables } }; + } + } else if (colorScheme) { + if (rule === 'media') { + return `@media (prefers-color-scheme: ${String(colorScheme)}) { :root`; + } + if (rule) { + return rule.replace('%s', String(colorScheme)); } - return `${theme.colorSchemeSelector}, [${theme.attribute}="${String(colorScheme)}"]`; - } - if (colorScheme) { - return `[${theme.attribute}="${String(colorScheme)}"]`; } - return theme.colorSchemeSelector; + return ':root'; }; diff --git a/packages/mui-material/src/styles/excludeVariablesFromRoot.ts b/packages/mui-material/src/styles/excludeVariablesFromRoot.ts index 6041d0f1547fcf..1c558562693f46 100644 --- a/packages/mui-material/src/styles/excludeVariablesFromRoot.ts +++ b/packages/mui-material/src/styles/excludeVariablesFromRoot.ts @@ -1,5 +1,5 @@ /** - * @internal These variables should not appear in the :root stylesheet when the `defaultMode="dark"` + * @internal These variables should not appear in the :root stylesheet when the `defaultColorScheme="dark"` */ const excludeVariablesFromRoot = (cssVarPrefix?: string) => [ ...[...Array(24)].map( diff --git a/packages/mui-material/src/styles/extendTheme.d.ts b/packages/mui-material/src/styles/extendTheme.d.ts index 6a01d0dae59322..c6eafa4422101e 100644 --- a/packages/mui-material/src/styles/extendTheme.d.ts +++ b/packages/mui-material/src/styles/extendTheme.d.ts @@ -276,6 +276,10 @@ export interface ColorSystem { } export interface CssVarsThemeOptions extends Omit { + /** + * @default 'light' + */ + defaultColorScheme?: SupportedColorScheme; /** * Prefix of the generated CSS variables * @default 'mui' @@ -288,27 +292,27 @@ export interface CssVarsThemeOptions extends Omit>; + colorSchemes?: Partial> & + (ExtendedColorScheme extends string ? Record : {}); /** - * 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 strategy to generate CSS variables * - * 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 'media' + * Generate CSS variables using [prefers-color-scheme](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme) * - * @example - * // class selector - * (colorScheme) => colorScheme !== 'light' ? `.theme-${colorScheme}` : ":root" + * @example '.mode-%s' + * Generate CSS variables within a class .mode-light, .mode-dark * - * @example - * // data-* attribute selector - * (colorScheme) => colorScheme !== 'light' ? `[data-theme="${colorScheme}"`] : ":root" + * @example '[data-mode-%s]' + * Generate CSS variables within a data attribute [data-mode-light], [data-mode-dark] + */ + colorSchemeSelector?: 'media' | 'class' | 'data' | string; + /** + * If `true`, the CSS color-scheme will not be set. + * https://developer.mozilla.org/en-US/docs/Web/CSS/color-scheme + * @default false */ - getSelector?: ( - colorScheme: SupportedColorScheme | undefined, - css: Record, - ) => string | Record; + disableCssColorScheme?: boolean; /** * A function to determine if the key, value should be attached as CSS Variable * `keys` is an array that represents the object path keys. @@ -427,6 +431,7 @@ export type ThemeCssVar = OverridableStringUnion< */ export interface CssVarsTheme extends ColorSystem { colorSchemes: Record; + colorSchemeSelector: 'media' | 'class' | 'data' | string; cssVarPrefix: string; vars: ThemeVars; getCssVar: (field: ThemeCssVar, ...vars: ThemeCssVar[]) => string; diff --git a/packages/mui-material/src/styles/extendTheme.js b/packages/mui-material/src/styles/extendTheme.js index 96511b8d9a6d9a..7901d3d223cbfa 100644 --- a/packages/mui-material/src/styles/extendTheme.js +++ b/packages/mui-material/src/styles/extendTheme.js @@ -1,7 +1,12 @@ +import MuiError from '@mui/internal-babel-macros/MuiError.macro'; import deepmerge from '@mui/utils/deepmerge'; import { unstable_createGetCssVar as systemCreateGetCssVar, createSpacing } from '@mui/system'; import { createUnarySpacing } from '@mui/system/spacing'; -import { prepareCssVars, prepareTypographyVars } from '@mui/system/cssVars'; +import { + prepareCssVars, + prepareTypographyVars, + createGetColorSchemeSelector, +} from '@mui/system/cssVars'; import styleFunctionSx, { unstable_defaultSxConfig as defaultSxConfig, } from '@mui/system/styleFunctionSx'; @@ -90,56 +95,110 @@ const silent = (fn) => { export const createGetCssVar = (cssVarPrefix = 'mui') => systemCreateGetCssVar(cssVarPrefix); +function getOpacity(mode) { + return { + inputPlaceholder: mode === 'dark' ? 0.5 : 0.42, + inputUnderline: mode === 'dark' ? 0.7 : 0.42, + switchTrackDisabled: mode === 'dark' ? 0.2 : 0.12, + switchTrack: mode === 'dark' ? 0.3 : 0.38, + }; +} +function getOverlays(mode) { + return mode === 'dark' ? defaultDarkOverlays : []; +} + +function attachColorScheme(colorSchemes, scheme, restTheme, colorScheme) { + if (!scheme) { + return undefined; + } + scheme = scheme === true ? {} : scheme; + const mode = colorScheme === 'dark' ? 'dark' : 'light'; + const { palette, ...muiTheme } = createThemeWithoutVars({ + ...restTheme, + palette: { + mode, + ...scheme?.palette, + }, + }); + colorSchemes[colorScheme] = { + ...scheme, + palette, + opacity: { + ...getOpacity(mode), + ...scheme?.opacity, + }, + overlays: scheme?.overlays || getOverlays(mode), + }; + return muiTheme; +} + +/** + * A default `extendTheme` comes with a single color scheme, either `light` or `dark` based on the `defaultColorScheme`. + * This is better suited for apps that only need a single color scheme. + * + * To enable built-in `light` and `dark` color schemes, either: + * 1. provide a `colorSchemeSelector` to define how the color schemes will change. + * 2. provide `colorSchemes.dark` will set `colorSchemeSelector: 'media'` by default. + */ export default function extendTheme(options = {}, ...args) { const { - colorSchemes: colorSchemesInput = {}, + colorSchemes: colorSchemesInput = { light: true }, + defaultColorScheme: defaultColorSchemeInput, + disableCssColorScheme = false, cssVarPrefix = 'mui', shouldSkipGeneratingVar = defaultShouldSkipGeneratingVar, - getSelector, + colorSchemeSelector: selector = colorSchemesInput.light && colorSchemesInput.dark + ? 'media' + : undefined, ...input } = options; + const firstColorScheme = Object.keys(colorSchemesInput)[0]; + const defaultColorScheme = + defaultColorSchemeInput || + (colorSchemesInput.light && firstColorScheme !== 'light' ? 'light' : firstColorScheme); const getCssVar = createGetCssVar(cssVarPrefix); + const { + [defaultColorScheme]: defaultSchemeInput, + light: builtInLight, + dark: builtInDark, + ...customColorSchemes + } = colorSchemesInput; + const colorSchemes = { ...customColorSchemes }; + let defaultScheme = defaultSchemeInput; - const { palette: lightPalette, ...muiTheme } = createThemeWithoutVars({ - ...input, - ...(colorSchemesInput.light && { palette: colorSchemesInput.light?.palette }), - }); - const { palette: darkPalette } = createThemeWithoutVars({ - palette: { mode: 'dark', ...colorSchemesInput.dark?.palette }, - }); + // For built-in light and dark color schemes, ensure that the value is valid if they are the default color scheme. + if ( + (defaultColorScheme === 'dark' && !('dark' in colorSchemesInput)) || + (defaultColorScheme === 'light' && !('light' in colorSchemesInput)) + ) { + defaultScheme = true; + } + + if (!defaultScheme) { + throw new MuiError( + 'MUI: The provided `colorSchemes.%s` to the `extendTheme` function is either missing or invalid.', + defaultColorScheme, + ); + } + + // Create the palette for the default color scheme, either `light`, `dark`, or custom color scheme. + const muiTheme = attachColorScheme(colorSchemes, defaultScheme, input, defaultColorScheme); + + if (builtInLight && !colorSchemes.light) { + attachColorScheme(colorSchemes, builtInLight, undefined, 'light'); + } + + if (builtInDark && !colorSchemes.dark) { + attachColorScheme(colorSchemes, builtInDark, undefined, 'dark'); + } let theme = { - defaultColorScheme: 'light', + defaultColorScheme, ...muiTheme, cssVarPrefix, + colorSchemeSelector: selector, getCssVar, - colorSchemes: { - ...colorSchemesInput, - light: { - ...colorSchemesInput.light, - palette: lightPalette, - opacity: { - inputPlaceholder: 0.42, - inputUnderline: 0.42, - switchTrackDisabled: 0.12, - switchTrack: 0.38, - ...colorSchemesInput.light?.opacity, - }, - overlays: colorSchemesInput.light?.overlays || [], - }, - dark: { - ...colorSchemesInput.dark, - palette: darkPalette, - opacity: { - inputPlaceholder: 0.5, - inputUnderline: 0.7, - switchTrackDisabled: 0.2, - switchTrack: 0.3, - ...colorSchemesInput.dark?.opacity, - }, - overlays: colorSchemesInput.dark?.overlays || defaultDarkOverlays, - }, - }, + colorSchemes, font: { ...prepareTypographyVars(muiTheme.typography), ...muiTheme.font }, spacing: getSpacingVal(input.spacing), }; @@ -155,10 +214,11 @@ export default function extendTheme(options = {}, ...args) { }; // attach black & white channels to common node - if (key === 'light') { + if (palette.mode === 'light') { setColor(palette.common, 'background', '#fff'); setColor(palette.common, 'onBackground', '#000'); - } else { + } + if (palette.mode === 'dark') { setColor(palette.common, 'background', '#000'); setColor(palette.common, 'onBackground', '#fff'); } @@ -182,7 +242,7 @@ export default function extendTheme(options = {}, ...args) { 'TableCell', 'Tooltip', ]); - if (key === 'light') { + if (palette.mode === 'light') { setColor(palette.Alert, 'errorColor', safeDarken(palette.error.light, 0.6)); setColor(palette.Alert, 'infoColor', safeDarken(palette.info.light, 0.6)); setColor(palette.Alert, 'successColor', safeDarken(palette.success.light, 0.6)); @@ -194,22 +254,22 @@ export default function extendTheme(options = {}, ...args) { setColor( palette.Alert, 'errorFilledColor', - silent(() => lightPalette.getContrastText(palette.error.main)), + silent(() => palette.getContrastText(palette.error.main)), ); setColor( palette.Alert, 'infoFilledColor', - silent(() => lightPalette.getContrastText(palette.info.main)), + silent(() => palette.getContrastText(palette.info.main)), ); setColor( palette.Alert, 'successFilledColor', - silent(() => lightPalette.getContrastText(palette.success.main)), + silent(() => palette.getContrastText(palette.success.main)), ); setColor( palette.Alert, 'warningFilledColor', - silent(() => lightPalette.getContrastText(palette.warning.main)), + silent(() => palette.getContrastText(palette.warning.main)), ); setColor(palette.Alert, 'errorStandardBg', safeLighten(palette.error.light, 0.9)); setColor(palette.Alert, 'infoStandardBg', safeLighten(palette.info.light, 0.9)); @@ -251,7 +311,7 @@ export default function extendTheme(options = {}, ...args) { setColor( palette.SnackbarContent, 'color', - silent(() => lightPalette.getContrastText(snackbarContentBackground)), + silent(() => palette.getContrastText(snackbarContentBackground)), ); setColor( palette.SpeedDialAction, @@ -270,7 +330,8 @@ export default function extendTheme(options = {}, ...args) { setColor(palette.Switch, 'warningDisabledColor', safeLighten(palette.warning.main, 0.62)); setColor(palette.TableCell, 'border', safeLighten(safeAlpha(palette.divider, 1), 0.88)); setColor(palette.Tooltip, 'bg', safeAlpha(palette.grey[700], 0.92)); - } else { + } + if (palette.mode === 'dark') { setColor(palette.Alert, 'errorColor', safeLighten(palette.error.light, 0.6)); setColor(palette.Alert, 'infoColor', safeLighten(palette.info.light, 0.6)); setColor(palette.Alert, 'successColor', safeLighten(palette.success.light, 0.6)); @@ -282,22 +343,22 @@ export default function extendTheme(options = {}, ...args) { setColor( palette.Alert, 'errorFilledColor', - silent(() => darkPalette.getContrastText(palette.error.dark)), + silent(() => palette.getContrastText(palette.error.dark)), ); setColor( palette.Alert, 'infoFilledColor', - silent(() => darkPalette.getContrastText(palette.info.dark)), + silent(() => palette.getContrastText(palette.info.dark)), ); setColor( palette.Alert, 'successFilledColor', - silent(() => darkPalette.getContrastText(palette.success.dark)), + silent(() => palette.getContrastText(palette.success.dark)), ); setColor( palette.Alert, 'warningFilledColor', - silent(() => darkPalette.getContrastText(palette.warning.dark)), + silent(() => palette.getContrastText(palette.warning.dark)), ); setColor(palette.Alert, 'errorStandardBg', safeDarken(palette.error.light, 0.9)); setColor(palette.Alert, 'infoStandardBg', safeDarken(palette.info.light, 0.9)); @@ -341,7 +402,7 @@ export default function extendTheme(options = {}, ...args) { setColor( palette.SnackbarContent, 'color', - silent(() => darkPalette.getContrastText(snackbarContentBackground)), + silent(() => palette.getContrastText(snackbarContentBackground)), ); setColor( palette.SpeedDialAction, @@ -420,12 +481,11 @@ export default function extendTheme(options = {}, ...args) { const parserConfig = { prefix: cssVarPrefix, + disableCssColorScheme, shouldSkipGeneratingVar, - getSelector: getSelector || defaultGetSelector(theme), + getSelector: defaultGetSelector(theme), }; 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; @@ -435,7 +495,7 @@ export default function extendTheme(options = {}, ...args) { theme.generateSpacing = function generateSpacing() { return createSpacing(input.spacing, createUnarySpacing(this)); }; - theme.getColorSchemeSelector = (colorScheme) => `[${theme.attribute}="${colorScheme}"] &`; + theme.getColorSchemeSelector = createGetColorSchemeSelector(selector); theme.spacing = theme.generateSpacing(); theme.shouldSkipGeneratingVar = shouldSkipGeneratingVar; theme.unstable_sxConfig = { diff --git a/packages/mui-material/src/styles/extendTheme.spec.ts b/packages/mui-material/src/styles/extendTheme.spec.ts index ae8722c899c93b..fc32973b6f0f16 100644 --- a/packages/mui-material/src/styles/extendTheme.spec.ts +++ b/packages/mui-material/src/styles/extendTheme.spec.ts @@ -18,3 +18,9 @@ theme.getCssVar(''); theme.getCssVar('custom-color'); // @ts-expect-error theme.getCssVar('palette-primary-main', ''); + +// dark only application +extendTheme({ colorSchemes: { dark: true } }); + +// built-in light and dark modes +extendTheme({ colorSchemes: { light: true, dark: true } }); diff --git a/packages/mui-material/src/styles/extendTheme.test.js b/packages/mui-material/src/styles/extendTheme.test.js index f2ac5fa8dc9502..3f8d8f5e9ced3f 100644 --- a/packages/mui-material/src/styles/extendTheme.test.js +++ b/packages/mui-material/src/styles/extendTheme.test.js @@ -1,8 +1,9 @@ import * as React from 'react'; import { expect } from 'chai'; +import sinon from 'sinon'; import { createRenderer } from '@mui/internal-test-utils'; import Button from '@mui/material/Button'; -import { CssVarsProvider, extendTheme, styled } from '@mui/material/styles'; +import { CssVarsProvider, extendTheme } from '@mui/material/styles'; import { deepOrange, green } from '@mui/material/colors'; describe('extendTheme', () => { @@ -32,10 +33,69 @@ describe('extendTheme', () => { window.matchMedia = originalMatchmedia; }); - it('should have a colorSchemes', () => { + it('should have a light colorScheme by default', () => { const theme = extendTheme(); expect(typeof extendTheme).to.equal('function'); expect(typeof theme.colorSchemes).to.equal('object'); + expect(typeof theme.colorSchemes.light).to.equal('object'); + expect(theme.colorSchemes.dark).to.equal(undefined); + }); + + it('should have a light as a default colorScheme regardless of key order', () => { + const theme = extendTheme({ + colorSchemes: { dark: true, light: true }, + }); + expect(theme.defaultColorScheme).to.equal('light'); + }); + + it('should have "media" colorSchemeSelector', () => { + const theme = extendTheme({ colorSchemeSelector: 'media' }); + expect(theme.colorSchemeSelector).to.equal('media'); + }); + + it('should have CSS color-scheme by default', () => { + const theme = extendTheme(); + sinon.assert.match(theme.generateStyleSheets()[1], { + ':root': { + colorScheme: 'light', + }, + }); + }); + + it('should have CSS color-scheme: dark', () => { + const theme = extendTheme({ defaultColorScheme: 'dark' }); + sinon.assert.match(theme.generateStyleSheets()[1], { + ':root': { + colorScheme: 'dark', + }, + }); + }); + + it('should throw error if the default color scheme is invalid', () => { + expect(() => + extendTheme({ colorSchemes: { dark: false }, defaultColorScheme: 'dark' }), + ).to.throw( + 'MUI: The provided `colorSchemes.dark` to the `extendTheme` function is either missing or invalid.', + ); + }); + + it('should throw error if the default color scheme is missing', () => { + expect(() => extendTheme({ defaultColorScheme: 'paper' })).to.throw( + 'MUI: The provided `colorSchemes.paper` to the `extendTheme` function is either missing or invalid.', + ); + }); + + it('should not attach to `colorSchemes` if the provided scheme is invalid', () => { + const theme = extendTheme({ colorSchemes: { dark: null, light: true } }); + expect(theme.colorSchemes.dark).to.equal(undefined); + }); + + it('disableCssColorScheme should remove CSS color-scheme', () => { + const theme = extendTheme({ disableCssColorScheme: true }); + expect(theme.generateStyleSheets()[1][':root'].colorScheme).to.equal(undefined); + + const theme2 = extendTheme({ defaultColorScheme: 'dark', disableCssColorScheme: true }); + expect(theme2.generateStyleSheets()[1][':root'].colorScheme).to.equal(undefined); }); it('should have the custom color schemes', () => { @@ -52,59 +112,61 @@ describe('extendTheme', () => { it('should generate color channels', () => { const theme = extendTheme(); - expect(theme.colorSchemes.dark.palette.background.defaultChannel).to.equal('18 18 18'); + expect(theme.colorSchemes.light.palette.background.defaultChannel).to.equal('255 255 255'); - expect(theme.colorSchemes.dark.palette.background.paperChannel).to.equal('18 18 18'); expect(theme.colorSchemes.light.palette.background.paperChannel).to.equal('255 255 255'); - 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.dark.palette.primary.contrastTextChannel).to.equal('0 0 0'); - 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.light.palette.primary.contrastTextChannel).to.equal('255 255 255'); - 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.dark.palette.secondary.contrastTextChannel).to.equal('0 0 0'); - 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.light.palette.secondary.contrastTextChannel).to.equal('255 255 255'); - 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.light.palette.text.primaryChannel).to.equal('0 0 0'); expect(theme.colorSchemes.light.palette.text.secondaryChannel).to.equal('0 0 0'); - expect(theme.colorSchemes.dark.palette.dividerChannel).to.equal('255 255 255'); - expect(theme.colorSchemes.light.palette.dividerChannel).to.equal('0 0 0'); - expect(theme.colorSchemes.dark.palette.action.activeChannel).to.equal('255 255 255'); expect(theme.colorSchemes.light.palette.action.activeChannel).to.equal('0 0 0'); - expect(theme.colorSchemes.dark.palette.action.selectedChannel).to.equal('255 255 255'); expect(theme.colorSchemes.light.palette.action.selectedChannel).to.equal('0 0 0'); }); + it('should generate dark color channels', () => { + const theme = extendTheme({ defaultColorScheme: 'dark' }); + + expect(theme.colorSchemes.dark.palette.background.defaultChannel).to.equal('18 18 18'); + + expect(theme.colorSchemes.dark.palette.background.paperChannel).to.equal('18 18 18'); + + 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.dark.palette.primary.contrastTextChannel).to.equal('0 0 0'); + + 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.dark.palette.secondary.contrastTextChannel).to.equal('0 0 0'); + + 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.dividerChannel).to.equal('255 255 255'); + + expect(theme.colorSchemes.dark.palette.action.activeChannel).to.equal('255 255 255'); + + expect(theme.colorSchemes.dark.palette.action.selectedChannel).to.equal('255 255 255'); + }); + it('should generate common background, onBackground channels', () => { const theme = extendTheme({ colorSchemes: { - dark: { - palette: { - common: { - onBackground: '#f9f9f9', // this should not be overridden - }, - }, - }, light: { palette: { common: { @@ -118,7 +180,21 @@ describe('extendTheme', () => { expect(theme.colorSchemes.light.palette.common.backgroundChannel).to.equal('249 249 249'); expect(theme.colorSchemes.light.palette.common.onBackground).to.equal('#000'); expect(theme.colorSchemes.light.palette.common.onBackgroundChannel).to.equal('0 0 0'); + }); + it('should generate dark common background, onBackground channels', () => { + const theme = extendTheme({ + defaultColorScheme: 'dark', + colorSchemes: { + dark: { + palette: { + common: { + onBackground: '#f9f9f9', // this should not be overridden + }, + }, + }, + }, + }); expect(theme.colorSchemes.dark.palette.common.background).to.equal('#000'); expect(theme.colorSchemes.dark.palette.common.backgroundChannel).to.equal('0 0 0'); expect(theme.colorSchemes.dark.palette.common.onBackground).to.equal('#f9f9f9'); @@ -212,6 +288,10 @@ describe('extendTheme', () => { switchTrackDisabled: 0.12, switchTrack: 0.38, }); + }); + + it('should provide the default dark opacities', () => { + const theme = extendTheme({ defaultColorScheme: 'dark' }); expect(theme.colorSchemes.dark.opacity).to.deep.equal({ inputPlaceholder: 0.5, inputUnderline: 0.7, @@ -228,6 +308,18 @@ describe('extendTheme', () => { inputPlaceholder: 1, }, }, + }, + }); + expect(theme.colorSchemes.light.opacity).to.deep.include({ + inputPlaceholder: 1, + inputUnderline: 0.42, + }); + }); + + it('should allow overriding of the default dark opacities', () => { + const theme = extendTheme({ + defaultColorScheme: 'dark', + colorSchemes: { dark: { opacity: { inputPlaceholder: 0.2, @@ -235,10 +327,6 @@ describe('extendTheme', () => { }, }, }); - expect(theme.colorSchemes.light.opacity).to.deep.include({ - inputPlaceholder: 1, - inputUnderline: 0.42, - }); expect(theme.colorSchemes.dark.opacity).to.deep.include({ inputPlaceholder: 0.2, inputUnderline: 0.7, @@ -250,6 +338,10 @@ describe('extendTheme', () => { it('should provide the default array', () => { const theme = extendTheme(); expect(theme.colorSchemes.light.overlays).to.have.length(0); + }); + + it('should provide the default array for dark', () => { + const theme = extendTheme({ defaultColorScheme: 'dark' }); expect(theme.colorSchemes.dark.overlays).to.have.length(25); expect(theme.colorSchemes.dark.overlays[0]).to.equal(undefined); @@ -260,7 +352,10 @@ describe('extendTheme', () => { it('should override the array as expected', () => { const overlays = Array(24).fill('none'); - const theme = extendTheme({ colorSchemes: { dark: { overlays } } }); + const theme = extendTheme({ + defaultColorScheme: 'dark', + colorSchemes: { dark: { overlays } }, + }); expect(theme.colorSchemes.dark.overlays).to.equal(overlays); }); }); @@ -605,47 +700,112 @@ describe('extendTheme', () => { }); }); - it('should use the right selector with applyStyles', function test() { - const attribute = 'data-custom-color-scheme'; - let darkStyles = {}; - const Test = styled('div')(({ theme }) => { - darkStyles = theme.applyStyles('dark', { - backgroundColor: 'rgba(0, 0, 0, 0)', - }); - return null; + describe('dark color scheme only', () => { + it('should use dark as default color scheme', () => { + expect(extendTheme({ colorSchemes: { dark: true } }).defaultColorScheme).to.deep.equal( + 'dark', + ); }); - render( - - - , - ); + it('should not have colorSchemeSelector', () => { + expect(extendTheme({ colorSchemes: { dark: true } }).colorSchemeSelector).to.deep.equal( + undefined, + ); + }); - expect(darkStyles).to.deep.equal({ - [`*:where([${attribute}="dark"]) &`]: { - backgroundColor: 'rgba(0, 0, 0, 0)', - }, + it('should have dark palette and not light color scheme', () => { + const theme = extendTheme({ colorSchemes: { dark: true } }); + expect(theme.colorSchemes.dark.palette.text.primary).to.equal('#fff'); + expect(theme.colorSchemes.light).to.equal(undefined); }); }); - it("should `generateStyleSheets` based on the theme's attribute and colorSchemeSelector", () => { - const theme = extendTheme(); + describe('light and dark color schemes', () => { + it('should use prefers-color-scheme (`media`) by default', () => { + const theme = extendTheme({ colorSchemes: { light: true, dark: true } }); + expect(theme.generateStyleSheets().flatMap((sheet) => Object.keys(sheet))).to.deep.equal([ + ':root', + ':root', + '@media (prefers-color-scheme: dark) { :root', + ]); + }); + + it('[media] should use prefers-color-scheme for styling', () => { + const theme = extendTheme({ colorSchemes: { light: true, dark: true } }); + + expect(theme.getColorSchemeSelector('light')).to.equal( + '@media (prefers-color-scheme: light)', + ); + expect(theme.getColorSchemeSelector('dark')).to.equal('@media (prefers-color-scheme: dark)'); + }); + + it('[media] should use prefers-color-scheme with dark as default', () => { + const theme = extendTheme({ + colorSchemes: { light: true, dark: true }, + defaultColorScheme: 'dark', + }); + expect(theme.generateStyleSheets().flatMap((sheet) => Object.keys(sheet))).to.deep.equal([ + ':root', + ':root', + '@media (prefers-color-scheme: dark) { :root', // this key targets excluded variables for dark + '@media (prefers-color-scheme: light) { :root', + ]); + }); + + it('should use default class selector', () => { + const theme = extendTheme({ + colorSchemes: { light: true, dark: true }, + colorSchemeSelector: 'class', + }); + expect(theme.generateStyleSheets().flatMap((sheet) => Object.keys(sheet))).to.deep.equal([ + ':root', + ':root', + '.dark', + ]); + }); - 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"]', - ]); + it('should use a custom class selector', () => { + const theme = extendTheme({ + colorSchemes: { light: true, dark: true }, + colorSchemeSelector: '.mode-%s', + }); + expect(theme.generateStyleSheets().flatMap((sheet) => Object.keys(sheet))).to.deep.equal([ + ':root', + ':root', + '.mode-dark', + ]); + }); + + it('should use default data selector for styling', () => { + const theme = extendTheme({ + colorSchemes: { light: true, dark: true }, + colorSchemeSelector: 'data', + }); + + expect(theme.getColorSchemeSelector('light')).to.equal('[data-light] &'); + expect(theme.getColorSchemeSelector('dark')).to.equal('[data-dark] &'); + }); + + it('should use data attribute selector', () => { + const theme = extendTheme({ + colorSchemes: { light: true, dark: true }, + colorSchemeSelector: '[data-theme-%s]', + }); + expect(theme.generateStyleSheets().flatMap((sheet) => Object.keys(sheet))).to.deep.equal([ + ':root', + ':root', + '[data-theme-dark]', + ]); + }); + + it('should use data attribute for styling', () => { + const theme = extendTheme({ + colorSchemes: { light: true, dark: true }, + colorSchemeSelector: '[data-theme-%s]', + }); + + expect(theme.getColorSchemeSelector('light')).to.equal('[data-theme-light] &'); + expect(theme.getColorSchemeSelector('dark')).to.equal('[data-theme-dark] &'); + }); }); }); diff --git a/packages/mui-material/src/styles/shouldSkipGeneratingVar.ts b/packages/mui-material/src/styles/shouldSkipGeneratingVar.ts index 0146d320943c88..0bd931028287cd 100644 --- a/packages/mui-material/src/styles/shouldSkipGeneratingVar.ts +++ b/packages/mui-material/src/styles/shouldSkipGeneratingVar.ts @@ -1,6 +1,8 @@ export default function shouldSkipGeneratingVar(keys: string[]) { return ( - !!keys[0].match(/(cssVarPrefix|typography|mixins|breakpoints|direction|transitions)/) || + !!keys[0].match( + /(cssVarPrefix|colorSchemeSelector|typography|mixins|breakpoints|direction|transitions)/, + ) || !!keys[0].match(/sxConfig$/) || // ends with sxConfig (keys[0] === 'palette' && !!keys[1]?.match(/(mode|contrastThreshold|tonalOffset)/)) ); diff --git a/packages/mui-system/src/InitColorSchemeScript/InitColorSchemeScript.test.js b/packages/mui-system/src/InitColorSchemeScript/InitColorSchemeScript.test.js index b3e63d899c1d63..50f9ae3330d383 100644 --- a/packages/mui-system/src/InitColorSchemeScript/InitColorSchemeScript.test.js +++ b/packages/mui-system/src/InitColorSchemeScript/InitColorSchemeScript.test.js @@ -61,6 +61,55 @@ describe('InitColorSchemeScript', () => { expect(document.documentElement.getAttribute('data-mui-baz-scheme')).to.equal('flash'); }); + it('should switch between light and dark with class attribute', () => { + storage[DEFAULT_MODE_STORAGE_KEY] = 'light'; + storage[`${DEFAULT_COLOR_SCHEME_STORAGE_KEY}-light`] = 'foo'; + storage[`${DEFAULT_COLOR_SCHEME_STORAGE_KEY}-dark`] = 'bar'; + + const { container, rerender } = render(); + eval(container.firstChild.textContent); + expect(document.documentElement.classList.value).to.equal('mode-foo'); + + storage[DEFAULT_MODE_STORAGE_KEY] = 'dark'; + rerender(); + eval(container.firstChild.textContent); + expect(document.documentElement.classList.value).to.equal('mode-bar'); + + document.documentElement.classList.remove('mode-bar'); // cleanup + }); + + it('should switch between light and dark with data-%s attribute', () => { + storage[DEFAULT_MODE_STORAGE_KEY] = 'light'; + storage[`${DEFAULT_COLOR_SCHEME_STORAGE_KEY}-light`] = 'foo'; + storage[`${DEFAULT_COLOR_SCHEME_STORAGE_KEY}-dark`] = 'bar'; + + const { container, rerender } = render(); + eval(container.firstChild.textContent); + expect(document.documentElement.getAttribute('data-mode-foo')).to.equal(''); + expect(document.documentElement.getAttribute('data-mode-bar')).to.equal(null); + + storage[DEFAULT_MODE_STORAGE_KEY] = 'dark'; + rerender(); + eval(container.firstChild.textContent); + expect(document.documentElement.getAttribute('data-mode-bar')).to.equal(''); + expect(document.documentElement.getAttribute('data-mode-foo')).to.equal(null); + }); + + it('should switch between light and dark with data="%s" attribute', () => { + storage[DEFAULT_MODE_STORAGE_KEY] = 'light'; + storage[`${DEFAULT_COLOR_SCHEME_STORAGE_KEY}-light`] = 'foo'; + storage[`${DEFAULT_COLOR_SCHEME_STORAGE_KEY}-dark`] = 'bar'; + + const { container, rerender } = render(); + eval(container.firstChild.textContent); + expect(document.documentElement.getAttribute('data-mode')).to.equal('foo'); + + storage[DEFAULT_MODE_STORAGE_KEY] = 'dark'; + rerender(); + eval(container.firstChild.textContent); + expect(document.documentElement.getAttribute('data-mode')).to.equal('bar'); + }); + it('should set `dark` color scheme to body', () => { storage[DEFAULT_MODE_STORAGE_KEY] = 'dark'; storage[`${DEFAULT_COLOR_SCHEME_STORAGE_KEY}-dark`] = 'bar'; @@ -90,19 +139,11 @@ describe('InitColorSchemeScript', () => { expect(document.documentElement.getAttribute(DEFAULT_ATTRIBUTE)).to.equal('bright'); }); - it('defaultMode: `dark`', () => { - const { container } = render(); - eval(container.firstChild.textContent); - expect(document.documentElement.getAttribute(DEFAULT_ATTRIBUTE)).to.equal('dark'); - }); - - describe('defaultMode: `system`', () => { + describe('system preference', () => { it('should set dark color scheme to body, given prefers-color-scheme is `dark`', () => { window.matchMedia = createMatchMedia(true); - const { container } = render( - , - ); + const { container } = render(); eval(container.firstChild.textContent); expect(document.documentElement.getAttribute(DEFAULT_ATTRIBUTE)).to.equal('trueDark'); }); @@ -110,9 +151,7 @@ describe('InitColorSchemeScript', () => { it('should set light color scheme to body, given prefers-color-scheme is NOT `dark`', () => { window.matchMedia = createMatchMedia(false); - const { container } = render( - , - ); + const { container } = render(); eval(container.firstChild.textContent); expect(document.documentElement.getAttribute(DEFAULT_ATTRIBUTE)).to.equal('yellow'); }); diff --git a/packages/mui-system/src/InitColorSchemeScript/InitColorSchemeScript.tsx b/packages/mui-system/src/InitColorSchemeScript/InitColorSchemeScript.tsx index ff7892f469fcde..3d537ed6087971 100644 --- a/packages/mui-system/src/InitColorSchemeScript/InitColorSchemeScript.tsx +++ b/packages/mui-system/src/InitColorSchemeScript/InitColorSchemeScript.tsx @@ -8,11 +8,6 @@ export const DEFAULT_COLOR_SCHEME_STORAGE_KEY = 'color-scheme'; export const DEFAULT_ATTRIBUTE = 'data-color-scheme'; export interface InitColorSchemeScriptProps { - /** - * The mode to be used for the first visit - * @default 'light' - */ - defaultMode?: 'light' | 'dark' | 'system'; /** * The default color scheme to be used on the light mode * @default 'light' @@ -41,6 +36,9 @@ export interface InitColorSchemeScriptProps { /** * DOM attribute for applying color scheme * @default 'data-color-scheme' + * + * @example '.mode-%s' // for class based color scheme + * @example '[data-mode-%s]' // for data-attribute without '=' */ attribute?: string; /** @@ -51,7 +49,6 @@ export interface InitColorSchemeScriptProps { export default function InitColorSchemeScript(options?: InitColorSchemeScriptProps) { const { - defaultMode = 'light', defaultLightColorScheme = 'light', defaultDarkColorScheme = 'dark', modeStorageKey = DEFAULT_MODE_STORAGE_KEY, @@ -60,6 +57,25 @@ export default function InitColorSchemeScript(options?: InitColorSchemeScriptPro colorSchemeNode = 'document.documentElement', nonce, } = options || {}; + let setter = ''; + if (attribute.startsWith('.')) { + const selector = attribute.substring(1); + setter += `${colorSchemeNode}.classList.remove('${selector}'.replace('%s', light), '${selector}'.replace('%s', dark)); + ${colorSchemeNode}.classList.add('${selector}'.replace('%s', colorScheme));`; + } + const matches = attribute.match(/\[([^\]]+)\]/); // case [data-color-scheme=%s] or [data-color-scheme] + if (matches) { + const [attr, value] = matches[1].split('='); + if (!value) { + setter += `${colorSchemeNode}.removeAttribute('${attr}'.replace('%s', light)); + ${colorSchemeNode}.removeAttribute('${attr}'.replace('%s', dark));`; + } + setter += ` + ${colorSchemeNode}.setAttribute('${attr}'.replace('%s', colorScheme), ${value ? `${value}.replace('%s', colorScheme)` : '""'});`; + } else { + setter += `${colorSchemeNode}.setAttribute('${attribute}', colorScheme);`; + } + return (