diff --git a/docs/data/material/customization/css-theme-variables/configuration.md b/docs/data/material/customization/css-theme-variables/configuration.md index af198b2064d4ae..fb9ff1a2f5d49a 100644 --- a/docs/data/material/customization/css-theme-variables/configuration.md +++ b/docs/data/material/customization/css-theme-variables/configuration.md @@ -277,6 +277,92 @@ In the example below, all the components inside the `div` will always be dark: +## Nested and multiple theme providers + +A `ThemeProvider` writes the CSS theme variables to `:root`, so they apply to the whole page. +A nested or `scoped` provider sets them on its child element instead, so they only apply there. + +### Nested providers + +When a nested `ThemeProvider`'s variables differ from the outer theme, it adds a generated class to its child. +The class only sets the variables that differ, and it follows the outer light or dark mode: + +```jsx + + + +
+ +
+
+
+``` + +```html +
…
+``` + +```css +:root { + --mui-palette-primary-main: #1976d2; +} +.mui-theme-1x2y3z { + --mui-palette-primary-main: #9c27b0; +} +``` + +The provider doesn't render any element of its own, so the child has to be a single element that renders the `className` it receives. +With several children, a Fragment, or text, the variables aren't applied and an error is logged in development: wrap the children in one element, for example a `div`. +If the child is your own component, pass `className` on to its root element: + +```jsx +function Section({ className, children }) { + return
{children}
; +} +``` + +A nested provider whose theme has the same variables, for example one that only changes `components` defaults, accepts any children. + +Dialogs, menus, popovers, tooltips, and other components that render in a portal apply the class to their own root, so they use the nested theme too. +For elements that other libraries portal, use the `useThemeScope()` hook to get the class: + +```jsx +import { useThemeScope } from '@mui/material/styles'; + +function Toast() { + const themeScope = useThemeScope(); + return ReactDOM.createPortal(
…
, document.body); +} +``` + +### Sibling providers + +Two `ThemeProvider`s that aren't nested in each other both write to `:root` and overwrite each other, which logs a warning in development. +Add the `scoped` prop to the one that should only apply to its child, like an embedded widget: + +```jsx + + + + + +
+ +
+
+
+``` + +A scoped provider sets all of its variables on its child element, and has its own light or dark mode. +If the theme's `colorSchemeSelector` uses a data attribute, the child also needs to render the `data-*` props it receives. +Its mode isn't stored, so it doesn't overwrite the mode of the page's provider. +To remember it across visits, pass your own `modeStorageKey` and `colorSchemeStorageKey`. + +:::info +A CSS variable that you override on `:root` doesn't reach the elements under a scoped provider that sets it, because the closest declaration wins. +Override it on the child element instead, for example with its own class. +::: + ## Disabling CSS color scheme By default, `createTheme()` attaches a [CSS `color-scheme` property](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/color-scheme) based on the palette mode. diff --git a/docs/data/material/customization/theming/theming.md b/docs/data/material/customization/theming/theming.md index 5e3481df0516ce..35507125f64c9e 100644 --- a/docs/data/material/customization/theming/theming.md +++ b/docs/data/material/customization/theming/theming.md @@ -125,6 +125,10 @@ You can extend the outer theme by providing a function: {{"component": "file://./demos/theme-nesting-extend/index.ts"}} +With CSS theme variables, a nested theme whose variables differ from the outer theme sets them on its child element, so the child has to be a single element. +This doesn't apply to a theme passed as a function, which still uses the outer theme's CSS variables. +See [Nested and multiple theme providers](/material-ui/customization/css-theme-variables/configuration/#nested-and-multiple-theme-providers) for details. + ## CSS theme variables To generate CSS variables from the theme, set `cssVariables` to `true` in the theme configuration and pass it to the `ThemeProvider`: diff --git a/docs/data/material/migration/upgrade-to-v10/upgrade-to-v10.md b/docs/data/material/migration/upgrade-to-v10/upgrade-to-v10.md index 16c5b27fa3c6d8..cfb56dff56db4c 100644 --- a/docs/data/material/migration/upgrade-to-v10/upgrade-to-v10.md +++ b/docs/data/material/migration/upgrade-to-v10/upgrade-to-v10.md @@ -1,3 +1,32 @@ # Upgrade to v10

This guide explains how to upgrade from Material UI v9 to v10.

+ +## Breaking changes + +### Theme + +#### Nested ThemeProviders with CSS theme variables + +A nested `ThemeProvider` whose theme uses CSS theme variables now applies its theme. +Previously, its variables were ignored when it had the same `cssVarPrefix` as the outer theme, so its subtree kept the outer theme's colors. + +When its variables differ from the outer theme, it sets them with a class on its child, so give it a single child element: + +```diff + +-
+-
++
++
++
++
+ +``` + +With several children, a Fragment, or a child that doesn't render its `className`, the variables aren't applied, and the first two log an error in development. + +Two `ThemeProvider`s that aren't nested in each other still overwrite each other's variables on `:root`, and now log a warning in development. +Add the `scoped` prop to the one that should only apply to its child, like an embedded widget. + +See [Nested and multiple theme providers](/material-ui/customization/css-theme-variables/configuration/#nested-and-multiple-theme-providers) for details. diff --git a/docs/pages/experiments/theme-provider-scoping.tsx b/docs/pages/experiments/theme-provider-scoping.tsx new file mode 100644 index 00000000000000..49869d385f223f --- /dev/null +++ b/docs/pages/experiments/theme-provider-scoping.tsx @@ -0,0 +1,695 @@ +'use client'; +// Temporary playground for https://github.com/mui/material-ui/pull/49581. Remove before merging. +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import createCache, { EmotionCache } from '@emotion/cache'; +import { CacheProvider } from '@emotion/react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Dialog from '@mui/material/Dialog'; +import DialogContent from '@mui/material/DialogContent'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Stack from '@mui/material/Stack'; +import Tooltip from '@mui/material/Tooltip'; +import Typography from '@mui/material/Typography'; +import { + ThemeProvider as MaterialThemeProvider, + createTheme, + useColorScheme, + useThemeScope, +} from '@mui/material/styles'; +import { AppLayoutHead as Head } from '@mui/internal-core-docs/AppLayout'; + +// The CSS variables props (`scoped`, `colorSchemeNode`, …) are only typed with the CSS variables augmentation. +const ThemeProvider = MaterialThemeProvider as React.ComponentType; + +function createColorTheme(light: object, dark: object) { + return createTheme({ + cssVariables: { colorSchemeSelector: 'class' }, + colorSchemes: { light: { palette: light }, dark: { palette: dark } }, + }); +} + +const appTheme = createColorTheme({}, {}); +const sameVarsTheme = createTheme({ + cssVariables: { colorSchemeSelector: 'class' }, + colorSchemes: { light: true, dark: true }, + components: { MuiButton: { defaultProps: { disableElevation: true } } }, +}); +const promoTheme = createColorTheme( + { primary: { main: '#9c27b0' } }, + { primary: { main: '#ce93d8' } }, +); +const accentTheme = createColorTheme( + { primary: { main: '#9c27b0' }, secondary: { main: '#2e7d32' } }, + { primary: { main: '#ce93d8' }, secondary: { main: '#81c784' } }, +); +const widgetTheme = createColorTheme( + { primary: { main: '#e65100' } }, + { primary: { main: '#ffb74d' } }, +); +const widgetDataTheme = createTheme({ + cssVariables: { colorSchemeSelector: 'data' }, + colorSchemes: { + light: { palette: { primary: { main: '#e65100' } } }, + dark: { palette: { primary: { main: '#ffb74d' } } }, + }, +}); +const forcedDarkTheme = createTheme({ + cssVariables: { colorSchemeSelector: 'class' }, + palette: { mode: 'dark', primary: { main: '#f48fb1' } }, +}); + +// --------------------------------------------------------------------------- +// Each scenario renders in its own iframe, because root providers write to `:root`. + +interface FrameValue { + document: Document; + window: Window; +} + +const FrameContext = React.createContext(null); + +function useFrame() { + return React.useContext(FrameContext)!; +} + +function Frame(props: { children: React.ReactNode; onReady: (frame: FrameValue) => void }) { + const { children, onReady } = props; + const ref = React.useRef(null); + const [frame, setFrame] = React.useState<(FrameValue & { cache: EmotionCache }) | null>(null); + const [height, setHeight] = React.useState(80); + // Emotion keys only allow lowercase letters and hyphens, so the id's other characters become letters. + const id = React.useId(); + const cacheKey = `frame-${id.toLowerCase().replace(/[^a-z]/g, (char) => String.fromCharCode(97 + (char.charCodeAt(0) % 26)))}`; + + React.useEffect(() => { + const iframe = ref.current!; + let observer: ResizeObserver | undefined; + const init = () => { + const doc = iframe.contentDocument!; + const value = { + document: doc, + window: iframe.contentWindow!, + cache: createCache({ + key: cacheKey, + container: doc.head, + }), + }; + // The iframe grows with its content, plus some room for menus and tooltips. + observer = new ResizeObserver(() => setHeight(doc.body.scrollHeight + 24)); + observer.observe(doc.body); + setFrame(value); + onReady(value); + }; + if (iframe.contentDocument?.readyState === 'complete' && iframe.contentDocument.body) { + init(); + } else { + iframe.addEventListener('load', init); + } + return () => { + iframe.removeEventListener('load', init); + observer?.disconnect(); + }; + }, [onReady, cacheKey]); + + return ( + +