diff --git a/apps/zero-runtime-next-app/next.config.js b/apps/zero-runtime-next-app/next.config.js index 57a52d81b50b8d..1b2a7f58906be7 100644 --- a/apps/zero-runtime-next-app/next.config.js +++ b/apps/zero-runtime-next-app/next.config.js @@ -1,24 +1,89 @@ /* eslint-env node */ // eslint-ignore-next-line import/no-unresolved const { withZeroPlugin } = require('@mui/zero-next-plugin'); -const { experimental_extendTheme: extendTheme } = require('@mui/material/styles'); +const { extendTheme } = require('@mui/zero-runtime'); + +/** + * @typedef {import('@mui/zero-next-plugin').ZeroPluginConfig} ZeroPluginConfig + */ const theme = extendTheme({ - cssVarPrefix: 'app', - components: { - MuiBadge: { - defaultProps: { - color: 'error', + 'max-width': '1100px', + 'border-radius': '12px', + 'font-mono': `ui-monospace, Menlo, Monaco, 'Cascadia Mono', 'Segoe UI Mono', 'Roboto Mono', 'Oxygen Mono', 'Ubuntu Monospace', 'Source Code Pro', 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace`, + colorSchemes: { + light: { + 'foreground-rgb': '0, 0, 0', + 'background-start-rgb': '214, 219, 220', + 'background-end-rgb': '255, 255, 255', + 'primary-glow': `conic-gradient( + from 180deg at 50% 50%, + #16abff33 0deg, + #0885ff33 55deg, + #54d6ff33 120deg, + #0071ff33 160deg, + transparent 360deg + )`, + 'secondary-glow': `radial-gradient(rgba(255, 255, 255, 1), rgba(255, 255, 255, 0))`, + title: { + 'start-rbg': '239, 245, 249', + 'end-rgb': '228, 232, 233', + border: `conic-gradient( + #00000080, + #00000040, + #00000030, + #00000020, + #00000010, + #00000010, + #00000080 + )`, + }, + callout: { + rgb: '238, 240, 241', + 'border-rgb': '172, 175, 176', + }, + card: { + rgb: '180, 185, 188', + 'border-rgb': '131, 134, 135', + }, + }, + dark: { + 'foreground-rgb': '255, 255, 255', + 'background-start-rgb': '0, 0, 0', + 'background-end-rgb': '0, 0, 0', + 'primary-glow': `radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0))`, + 'secondary-glow': `linear-gradient( + to bottom right, + rgba(1, 65, 255, 0), + rgba(1, 65, 255, 0), + rgba(1, 65, 255, 0.3) + )`, + title: { + 'start-rbg': '2, 13, 46', + 'end-rgb': '2, 5, 19', + border: `conic-gradient( + #ffffff80, + #ffffff40, + #ffffff30, + #ffffff20, + #ffffff10, + #ffffff10, + #ffffff80 + )`, + }, + callout: { + rgb: '20, 20, 20', + 'border-rgb': '108, 108, 108', + }, + card: { + rgb: '100, 100, 100', + 'border-rgb': '200, 200, 200', }, }, }, }); -theme.getColorSchemeSelector = (targetColorScheme) => - `[data-mui-color-scheme="${targetColorScheme}"] &`; -/** - * @typedef {import('@mui/zero-next-plugin').ZeroPluginConfig} ZeroPluginConfig - */ +// { [theme.getColorSchemeSelector('dark')]: { color: 'black' } } /** * @type {ZeroPluginConfig} diff --git a/apps/zero-runtime-next-app/src/app/globals.css b/apps/zero-runtime-next-app/src/app/globals.css index 1a51ef7fd13c01..f986282c43ac47 100644 --- a/apps/zero-runtime-next-app/src/app/globals.css +++ b/apps/zero-runtime-next-app/src/app/globals.css @@ -1,75 +1,3 @@ -:root { - --max-width: 1100px; - --border-radius: 12px; - --font-mono: ui-monospace, Menlo, Monaco, 'Cascadia Mono', 'Segoe UI Mono', 'Roboto Mono', - 'Oxygen Mono', 'Ubuntu Monospace', 'Source Code Pro', 'Fira Mono', 'Droid Sans Mono', - 'Courier New', monospace; - - --foreground-rgb: 0, 0, 0; - --background-start-rgb: 214, 219, 220; - --background-end-rgb: 255, 255, 255; - - --primary-glow: conic-gradient( - from 180deg at 50% 50%, - #16abff33 0deg, - #0885ff33 55deg, - #54d6ff33 120deg, - #0071ff33 160deg, - transparent 360deg - ); - --secondary-glow: radial-gradient(rgba(255, 255, 255, 1), rgba(255, 255, 255, 0)); - - --tile-start-rgb: 239, 245, 249; - --tile-end-rgb: 228, 232, 233; - --tile-border: conic-gradient( - #00000080, - #00000040, - #00000030, - #00000020, - #00000010, - #00000010, - #00000080 - ); - - --callout-rgb: 238, 240, 241; - --callout-border-rgb: 172, 175, 176; - --card-rgb: 180, 185, 188; - --card-border-rgb: 131, 134, 135; -} - -@media (prefers-color-scheme: dark) { - :root { - --foreground-rgb: 255, 255, 255; - --background-start-rgb: 0, 0, 0; - --background-end-rgb: 0, 0, 0; - - --primary-glow: radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0)); - --secondary-glow: linear-gradient( - to bottom right, - rgba(1, 65, 255, 0), - rgba(1, 65, 255, 0), - rgba(1, 65, 255, 0.3) - ); - - --tile-start-rgb: 2, 13, 46; - --tile-end-rgb: 2, 5, 19; - --tile-border: conic-gradient( - #ffffff80, - #ffffff40, - #ffffff30, - #ffffff20, - #ffffff10, - #ffffff10, - #ffffff80 - ); - - --callout-rgb: 20, 20, 20; - --callout-border-rgb: 108, 108, 108; - --card-rgb: 100, 100, 100; - --card-border-rgb: 200, 200, 200; - } -} - * { box-sizing: border-box; padding: 0; diff --git a/apps/zero-runtime-next-app/src/app/page.tsx b/apps/zero-runtime-next-app/src/app/page.tsx index 377dca936779c3..e0fcee40f2b8d0 100644 --- a/apps/zero-runtime-next-app/src/app/page.tsx +++ b/apps/zero-runtime-next-app/src/app/page.tsx @@ -1,10 +1,21 @@ import Image from 'next/image'; -import { styled } from '@mui/zero-runtime'; -import Badge from '@mui/material/Badge'; +import { styled, css } from '@mui/zero-runtime'; import styles from './page.module.css'; -const Main = styled.main({ - color: 'rgb(var(--foreground-rgb))', +const visuallyHidden = css({ + border: 0, + clip: 'rect(0 0 0 0)', + height: '1px', + margin: -1, + overflow: 'hidden', + padding: 0, + position: 'absolute', + whiteSpace: 'nowrap', + width: '1px', +}); + +const Main = styled.main(({ theme }) => ({ + color: theme.vars['foreground-rgb'], background: `linear-gradient(to bottom, transparent, rgb(var(--background-end-rgb))) rgb(var(--background-start-rgb))`, display: 'flex', flexDirection: 'column', @@ -12,7 +23,10 @@ const Main = styled.main({ alignItems: 'center', padding: '6rem', minHeight: '100vh', -}); + ...theme.applyStyles('dark', { + color: 'yellow', + }), +})); const Description = styled.div({ display: 'inherit', @@ -78,9 +92,7 @@ const Description = styled.div({ export default function Home() { return (
- -
Hey
-
+
I am invisible

Get started by editing  diff --git a/apps/zero-runtime-next-app/src/augment.d.ts b/apps/zero-runtime-next-app/src/augment.d.ts index c229ef09da32c6..21e002967fcc36 100644 --- a/apps/zero-runtime-next-app/src/augment.d.ts +++ b/apps/zero-runtime-next-app/src/augment.d.ts @@ -1,14 +1,34 @@ -import type { Theme } from '@mui/material/styles'; -import type {} from '@mui/material/themeCssVarsAugmentation'; -import '@mui/zero-runtime/theme'; +import type { ExtendTheme } from '@mui/zero-runtime'; declare module '@mui/zero-runtime/theme' { - export interface ThemeArgs { - theme: Omit & { - applyStyles: ( - colorScheme: 'light' | 'dark', - styles: Record, - ) => Record; + interface ThemeTokens { + 'max-width': string; + 'border-radius': string; + 'font-mono': string; + 'foreground-rgb': string; + 'background-start-rgb': string; + 'background-end-rgb': string; + 'primary-glow': string; + 'secondary-glow': string; + title: { + 'start-rgb': string; + 'end-rgb': string; + border: string; }; + callout: { + rgb: string; + 'border-rgb': string; + }; + card: { + rgb: string; + 'border-rgb': string; + }; + } + + interface ThemeArgs { + theme: ExtendTheme<{ + colorScheme: 'light' | 'dark'; + tokens: ThemeTokens; + }>; } } diff --git a/packages/mui-system/src/cssVars/prepareCssVars.ts b/packages/mui-system/src/cssVars/prepareCssVars.ts index c73dc7240ecdb7..1723fc9cd7679d 100644 --- a/packages/mui-system/src/cssVars/prepareCssVars.ts +++ b/packages/mui-system/src/cssVars/prepareCssVars.ts @@ -2,18 +2,24 @@ import { deepmerge } from '@mui/utils'; import cssVarsParser from './cssVarsParser'; export interface DefaultCssVarsTheme { - colorSchemes: Record; + colorSchemes?: Record; + defaultColorScheme?: string; } -function prepareCssVars>( +function prepareCssVars< + T extends DefaultCssVarsTheme, + ThemeVars extends Record, + Selector = any, +>( theme: T, parserConfig?: { prefix?: string; shouldSkipGeneratingVar?: (objectPathKeys: Array, value: string | number) => boolean; + getSelector?: (colorScheme: string | undefined, css: Record) => Selector; }, ) { // @ts-ignore - ignore components do not exist - const { colorSchemes = {}, components, ...otherTheme } = theme; + const { colorSchemes = {}, components, defaultColorScheme = 'light', ...otherTheme } = theme; const { vars: rootVars, css: rootCss, @@ -23,26 +29,33 @@ function prepareCssVars; vars: ThemeVars }> = {}; - const { light, ...otherColorSchemes } = colorSchemes; + const { [defaultColorScheme]: light, ...otherColorSchemes } = colorSchemes; Object.entries(otherColorSchemes || {}).forEach(([key, scheme]) => { const { vars, css, varsWithDefaults } = cssVarsParser(scheme, parserConfig); themeVars = deepmerge(themeVars, varsWithDefaults); colorSchemesMap[key] = { css, vars }; }); if (light) { - // light color scheme vars should be merged last to set as default + // default color scheme vars should be merged last to set as default const { css, vars, varsWithDefaults } = cssVarsParser(light, parserConfig); themeVars = deepmerge(themeVars, varsWithDefaults); - colorSchemesMap.light = { css, vars }; + colorSchemesMap[defaultColorScheme] = { css, vars }; } const generateCssVars = (colorScheme?: string) => { if (!colorScheme) { - return { css: { ...rootCss }, vars: rootVars }; + const css = { ...rootCss }; + return { + css, + vars: rootVars, + selector: parserConfig?.getSelector?.(colorScheme, css) || ':root', + }; } + const css = { ...colorSchemesMap[colorScheme].css }; return { - css: { ...colorSchemesMap[colorScheme].css }, + css, vars: colorSchemesMap[colorScheme].vars, + selector: parserConfig?.getSelector?.(colorScheme, css) || ':root', }; }; diff --git a/packages/zero-runtime/README.md b/packages/zero-runtime/README.md index 680f6510386cfd..f32aab05d20d91 100644 --- a/packages/zero-runtime/README.md +++ b/packages/zero-runtime/README.md @@ -1,94 +1,113 @@ # zero-runtime -A zero-runtime CSS-in-JS library that extracts the colocated css to it's own css files at build-time. +A zero-runtime CSS-in-JS library that extracts the colocated styles to their own CSS files at build-time. + +- [Getting started](#getting-started) + - [Next.js](#nextjs) + - [Vite](#vite) +- [Basic usage](#basic-usage) + - [Creating styles](#creating-styles) + - [Creating components](#creating-components) + - [Styling based on props](#styling-based-on-props) + - [Styling based on runtime values](#styling-based-on-runtime-values) + - [Styled component as a CSS selector](#styled-component-as-a-css-selector) + - [Typing props](#typing-props) +- [Theming](#theming) + - [Accesing theme values](#accesing-theme-values) + - [CSS variables support](#css-variables-support) + - [Color schemes](#color-schemes) + - [Switching color schemes](#switching-color-schemes) + - [TypeScript](#typescript) ## Getting started -Zero-runtime supports Next.js and Vite with future support for more bundlers—you must install the corresponding plugin, as shown below. - -The package currently has a dependency on `@mui/material` to initialize the theme object, but this is only at build time. There won't be any Material UI code at runtime if you're not using it otherwise—in that case, you can move it to dev dependencies instead (as shown with the plugin packages). +Zero-runtime supports Next.js and Vite with future support for more bundlers. You must install the corresponding plugin, as shown below. ### Next.js -#### Installation - ```bash -npm install @mui/zero-runtime @mui/material +npm install @mui/zero-runtime npm install --save-dev @mui/zero-next-plugin ``` -#### Configuration - -In your `next.config.js` file, - -1. Import the plugin +Then, in your `next.config.js` file, import the plugin and wrap the exported config object: ```js const { withZeroPlugin } = require('@mui/zero-next-plugin'); -``` - -2. Create a theme object -```js -const { experimental_extendTheme: extendTheme } = require('@mui/material/styles'); -const theme = extendTheme(); -``` - -3. Wrap the exported config object - -```js -module.exports = withZeroPlugin(nextConfig, { - theme, +module.exports = withZeroPlugin({ + // ... Your nextjs config. }); ``` ### Vite -#### Installation - ```bash -npm install @mui/zero-runtime @mui/material +npm install @mui/zero-runtime npm install --save-dev @mui/zero-vite-plugin ``` -#### Configuration - -In your vite config file, - -1. Import the plugin +Then, in your vite config file file, import the plugin and wrap the exported config object: ```js import { zeroVitePlugin } from '@mui/zero-vite-plugin'; + +export default defineConfig({ + plugins: [ + zeroVitePlugin(), + // ... Your other plugins. + ], +}); ``` -2. Create a theme object +## Basic usage + +> You must configure zero-runtime with [Next.js](#nextjs) or [Vite](#vite) first. + +### Creating styles + +Use the `css` API to create reusable styles: ```js -import { experimental_extendTheme as extendTheme } from '@mui/material/styles'; -const theme = extendTheme(); +import { css } from '@mui/zero-runtime'; + +const visuallyHidden = css({ + border: 0, + clip: 'rect(0 0 0 0)', + height: '1px', + margin: -1, + overflow: 'hidden', + padding: 0, + position: 'absolute', + whiteSpace: 'nowrap', + width: '1px', +}); + +function App() { + return

I am invisible
; +} ``` -3. Add the plugin to the `plugins` array. The position does not matter. +The call to the `css` function will be replaced with a unique string that represents the CSS class name for the generated styles. + +Use a callback function to get access to the [theme](#theming) values: ```js -export default defineConfig({ - plugins: [ - zeroVitePlugin({ - theme, - }), - // ... Your other plugins. - ], -}); +const title = css(({ theme }) => ({ + color: theme.colors.primary, + fontSize: theme.spacing.unit * 4, + fontFamily: theme.typography.fontFamily, +})); ``` -### Usage +### Creating components -In your source files, you can import the `styled` function from `@mui/zero-runtime`. The usage should be familiar if you've worked with Emotion or styled-components: +Use the `styled` API to create a component by passing styles at the end. The usage should be familiar if you've worked with Emotion or styled-components: ```js import { styled } from '@mui/zero-runtime'; -const Heading = styled.h1({ +const Heading = styled('div')({ fontSize: '4rem', fontWeight: 'bold', padding: '10px 0px', @@ -105,109 +124,93 @@ The zero-runtime package differs from "standard" runtime CSS-in-JS libraries in 2. Your styles must be declarative, and must account for all combinations of props that you want to style. 3. The theme lets you declare CSS tokens that become part of the CSS bundle after the build. Any other values and methods that it might have are only available during build time—not at runtime. This leads to smaller bundle sizes. -You can access the same `theme` object that you provided in the bundler config by declaring styles as callbacks—for example: +#### Styling based on props -```js -const Heading = styled.h1(({ theme }) => ({ - ...theme.typography.h1, -})); -``` +> 💡 This approach is recommended when the value of the prop is known at build time (finite values). -Visit the [Default theme viewer](https://mui.com/material-ui/customization/default-theme/) to learn more about the structure of the theme object. +Use the `variants` key to define styles for a combination of the component's props. -#### Styling variants +Each variant is an object with `props` and `style` keys. The styles are applied when the component's props match the `props` object. -The `styled` function must account for all combinations of props. If you're creating a button component that supports a `size` prop and a `color` prop, for example, you can use the `variants` API to define styles for each possible combination of the two: +**Example 1**: A button component with `small` and `large` sizes: ```jsx -const Button = styled.button(() => ({ +const Button = styled('button')({ border: 'none', - // ... other base css styles to be applied across all prop values. + padding: '0.75rem', + // ...other base styles variants: [ { - // prop combinations - props: { - color: 'primary', - }, - // styles to be applied when color="primary" is passed on the component - style: { - color: 'blue', - outline: '1px transparent lightblue', - }, - }, - { - props: { - color: 'secondary', - }, - // styles to be applied when color="secondary" is passed on the component - style: { - color: 'green', - outline: '1px transparent lightgreen', - }, - }, - { - props: { - size: 'large', - }, - style: { - padding: '0.5rem', - }, + props: { size: 'large' }, + style: { padding: '1rem' }, }, { - props: { - size: 'medium', - }, - style: { - padding: '0.25rem', - }, - }, - { - props: { - size: 'small', - }, - style: { - padding: '0.1rem', - }, + props: { size: 'small' }, + style: { padding: '0.5rem' }, }, + ], +}); + +; // padding: 0.75rem +; // padding: 1rem +; // padding: 0.5rem +``` + +**Example 2**: A button component with variants and colors: + +```jsx +const Button = styled('button')({ + border: 'none', + padding: '0.75rem', + // ...other base styles + variants: [ { - props: { - size: 'small', - color: 'primary', - }, - style: { - // Styles to be applied when ; +``` + +**Example 3**: Apply styles based on a condition: + +The value of the `props` can be a function that returns a boolean. If the function returns `true`, the styles are applied. + +```jsx +const Button = styled('button')({ + border: 'none', + padding: '0.75rem', + // ...other base styles + variants: [ { - // If key value pair doesn't suffice, you can use the callback syntax - // to return `true` if you want the styles to be applied - props({ children }) { - return !!children; - }, - style: { - // CSS - }, + props: (props) => props.variant !== 'contained', + style: { backgroundColor: 'transparent' }, }, ], -})); +}); ``` #### Styling based on runtime values -To style a component based on the runtime values of the props, you can declare a CSS property as a callback—for example: +> 💡 This approach is recommended when the value of a prop is **unknown** ahead of time or possibly unlimited values, e.g. styling based on the user's input. -```tsx -const Heading = styled.h1<{ isError?: boolean }>(({ theme }) => ({ - ...theme.typography.h1, +Use a callback function as a value to create a dynamic style for the specific CSS property: + +```jsx +const Heading = styled('h1')({ color: ({ isError }) => (isError ? 'red' : 'black'), -})); +}); ``` -This works through the use of CSS variables and inline styles. The CSS and JSX output will look something like this: +Zero-runtime will replace the callback with a CSS variable and inject the value through inline style. This makes it possible to create a static CSS file while still allowing dynamic styles. ```css .Heading_class_akjsdfb { - /* Other styles from `theme.typography` */ color: var(--Heading_class_akjsdfb-0); } ``` @@ -249,3 +252,202 @@ const ExtraHeading = styled(Heading)({ // ... overridden styled }); ``` + +#### Typing props + +If you use TypeScript, add the props typing before the styles to get the type checking: + +```tsx +const Heading = styled('h1')<{ isError?: boolean }>({ + color: ({ isError }) => (isError ? 'red' : 'black'), +}); +``` + +### Theming + +Theming is an **optional** feature that lets you reuse the same values, such as colors, spacing, and typography, across your application. It is a plain object of any structure that you can define in your config file. + +> **💡 Good to know**: +> +> The **theme** object are used at build time without relying on React context like common CSS-in-JS libraries. This means that components created by zero-runtime `styled` will be React Server Component by default and still get benefits from theming. + +For example, in Next.js, you can define a theme in the `next.config.js` file like this: + +```js +const { withZeroPlugin } = require('@mui/zero-next-plugin'); + +module.exports = withZeroPlugin( + { + // ...other nextConfig + }, + { + theme: { + colors: { + primary: 'tomato', + secondary: 'cyan', + }, + spacing: { + unit: 8, + }, + typography: { + fontFamily: 'Inter, sans-serif', + }, + // ...more keys and values, it's free style! + }, + }, +); +``` + +#### Accessing theme values + +A callback can be used with **styled** and **css** APIs to access the theme values: + +```js +const Heading = styled('h1')(({ theme }) => ({ + color: theme.colors.primary, + fontSize: theme.spacing.unit * 4, + fontFamily: theme.typography.fontFamily, +})); +``` + +#### CSS variables support + +Zero-runtime can generate CSS variables from the theme values when you wrap your theme with `extendTheme` utility. For example, in a `next.config.js` file: + +```js +const { withZeroPlugin, extendTheme } = require('@mui/zero-next-plugin'); + +module.exports = withZeroPlugin( + { + // ...other nextConfig + }, + { + theme: extendTheme({ + colors: { + primary: 'tomato', + secondary: 'cyan', + }, + spacing: { + unit: 8, + }, + typography: { + fontFamily: 'Inter, sans-serif', + }, + }), + }, +); +``` + +The `extendTheme` utility will go through the theme and create a `vars` object which represents the tokens that refer to CSS variables. + +```jsx +const theme = extendTheme({ + colors: { + primary: 'tomato', + secondary: 'cyan', + }, +}); + +console.log(theme.colors.primary); // 'tomato' +console.log(theme.vars.colors.primary); // 'var(--colors-primary)' +``` + +#### Color schemes + +Some tokens, especially color-related tokens, can have different values for different scenarios. For example in a daylight condition, the background color might be white, but in a dark condition, it might be black. + +The `extendTheme` utility lets you define theme with a special `colorSchemes` key: + +```jsx +extendTheme({ + colorSchemes: { + light: { + colors: { + background: '#f9f9f9', + foreground: '#121212', + }, + }, + dark: { + colors: { + background: '#212121', + foreground: '#fff', + }, + }, + }, +}); +``` + +In the above example, `light` (default) and `dark` color schemes are defined. The structure of each color scheme must be a plain object with keys and values. + +#### Switching color schemes + +By default, when `colorSchemes` is defined, zero-runtime uses the [`prefers-color-scheme`](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme) media query to switch between color schemes based on user's system settings. + +However, if you want to control the color scheme based on application logic, for example, using a button to switch between light and dark mode, you can customize the behavior by providing a `getSelector` function: + +```diff + extendTheme({ + colorSchemes: { + light: { ... }, + dark: { ... }, + }, ++ getSelector: (colorScheme) => colorScheme ? `.theme-${colorScheme}` : ':root', + }); +``` + +Note that you need to add a logic to a button by yourself. Here is an example of how to do it: + +```jsx +function App() { + return ( + + ); +} +``` + +#### CSS variables prefix + +You can add a prefix to the generated CSS variables by providing a `cssVarPrefix` option to the `extendTheme` utility: + +```jsx +extendTheme({ + cssVarPrefix: 'zero', +}); +``` + +The generated CSS variables will have the `zero` prefix: + +```css +:root { + --zero-colors-background: #f9f9f9; + --zero-colors-foreground: #121212; +} +``` + +#### TypeScript + +To get the type checking for the theme, you need to augment the theme type: + +```ts +// any file that is included in your tsconfig.json +import type { ExtendTheme } from '@mui/zero-runtime'; + +declare module '@mui/zero-runtime/theme' { + interface ThemeTokens { + // the structure of your theme + } + + interface ThemeArgs { + theme: ExtendTheme<{ + colorScheme: 'light' | 'dark'; + tokens: ThemeTokens; + }>; + } +} +``` diff --git a/packages/zero-runtime/src/extendTheme.ts b/packages/zero-runtime/src/extendTheme.ts new file mode 100644 index 00000000000000..abfee8b62139ab --- /dev/null +++ b/packages/zero-runtime/src/extendTheme.ts @@ -0,0 +1,171 @@ +import deepMerge from 'lodash/merge'; +import { prepareCssVars } from '@mui/system/cssVars'; +import type { SxConfig } from '@mui/system/styleFunctionSx'; +import type { CSSObject } from './base'; + +export interface ThemeInput { + /** + * The prefix to be used for the CSS variables. + */ + cssVarPrefix?: string; + /** + * The color schemes to be used for the theme. + */ + colorSchemes?: Record; + /** + * The default color scheme to be used for the theme. It must be one of the keys from `theme.colorSchemes`. + * Required when `colorSchemes` is provided. + * @default 'light' + */ + defaultColorScheme?: ColorScheme; + /** + * If provided, it will be used to create a selector for the color scheme. + * This is useful if you want to use class or data-* attributes to apply the color scheme. + * + * The default selector is `:root`. + * + * @example + * // class selector + * (colorScheme) => colorScheme ? `.theme-${colorScheme}` : ":root" + * + * @example + * // data-* attribute selector + * (colorScheme) => colorScheme ? `[data-theme="${colorScheme}"`] : ":root" + */ + getSelector?: ( + colorScheme: ColorScheme | undefined, + css: Record, + ) => string | Record; + /** + * A function to skip generating a CSS variable for a specific path or value. + * + * Note: properties with function as a value are always skipped. + * + * @example + * // skip the `meta.*` fields from generating CSS variables and `theme.vars` + * (keys, value) => keys[0] === 'meta' + * + */ + shouldSkipGeneratingVar?: (objectPathKeys: Array, value: string | number) => boolean; + components?: Partial< + Record< + string, + { + styleOverrides?: Record; + defaultProps: Record; + } + > + >; +} + +export type ExtendTheme< + Options extends { + colorScheme: string; + tokens: Record; + } = { + colorScheme: string; + tokens: Record; + }, +> = ThemeInput & + Options['tokens'] & { + vars: Options['tokens']; + applyStyles: ( + colorScheme: Options['colorScheme'], + styles: CSSObject, + ) => Record>; + getColorSchemeSelector: (colorScheme: Options['colorScheme']) => string; + generateCssVars: (colorScheme?: Options['colorScheme']) => { + css: Record; + selector: string | Record; + }; + unstable_sxConfig?: SxConfig; + }; + +export type Theme = ExtendTheme; + +/** + * A utility to tell zero-runtime to generate CSS variables for the theme. + */ +export default function extendTheme< + Options extends { + colorScheme: string; + tokens: Record; + } = { + colorScheme: string; + tokens: Record; + }, +>(theme: ThemeInput) { + const { + cssVarPrefix, + shouldSkipGeneratingVar, + getSelector = defaultGetSelector, + defaultColorScheme = 'light', + ...otherTheme + } = theme; + + function defaultGetSelector( + colorScheme: string | undefined, + css: Record, + ): string | Record { + if (colorScheme === 'light' && defaultColorScheme !== 'light') { + return { + '@media (prefers-color-scheme: light)': { + ':root': css, + }, + }; + } + if (colorScheme === 'dark' && defaultColorScheme !== 'dark') { + return { + '@media (prefers-color-scheme: dark)': { + ':root': css, + }, + }; + } + return ':root'; + } + + if ( + theme.colorSchemes && + (!defaultColorScheme || !Object.keys(theme.colorSchemes).includes(defaultColorScheme)) + ) { + throw new Error( + `Zero: \`defaultColorScheme\` must be one of ${JSON.stringify( + theme.colorSchemes, + )}, but got "\`${theme.defaultColorScheme}\`".`, + ); + } + + const parserConfig = { + prefix: cssVarPrefix, + shouldSkipGeneratingVar, + getSelector, + }; + const { generateCssVars } = prepareCssVars(otherTheme, parserConfig); + + let { vars } = generateCssVars(); + Object.entries(theme.colorSchemes || {}).forEach(([key]) => { + vars = deepMerge(vars, generateCssVars(key).vars); + }); + + const finalTheme = { + ...theme, + defaultColorScheme, + vars, + generateCssVars, + } as unknown as ExtendTheme<{ colorScheme: Options['colorScheme']; tokens: Options['tokens'] }>; + + finalTheme.getColorSchemeSelector = (colorScheme: string) => { + if (!theme.getSelector) { + return `@media (prefers-color-scheme: ${colorScheme})`; + } + return `:where(${theme.getSelector(colorScheme, {})}) &`; + }; + + finalTheme.applyStyles = function applyStyles(colorScheme, styles) { + return { + [this.getColorSchemeSelector(colorScheme)]: styles, + }; + }; + + return finalTheme; +} diff --git a/packages/zero-runtime/src/index.ts b/packages/zero-runtime/src/index.ts index 52c2a5e4bc993f..fb1923bf0e3803 100644 --- a/packages/zero-runtime/src/index.ts +++ b/packages/zero-runtime/src/index.ts @@ -4,3 +4,5 @@ export { default as keyframes } from './keyframes'; export { generateAtomics, atomics } from './generateAtomics'; export { default as css } from './css'; export { default as createUseThemeProps } from './createUseThemeProps'; +export { default as extendTheme } from './extendTheme'; +export type { Theme, ExtendTheme } from './extendTheme'; diff --git a/packages/zero-runtime/src/processors/styled.ts b/packages/zero-runtime/src/processors/styled.ts index b2fbc941a8fefa..44f6f435e33bc8 100644 --- a/packages/zero-runtime/src/processors/styled.ts +++ b/packages/zero-runtime/src/processors/styled.ts @@ -393,7 +393,7 @@ export class StyledProcessor extends BaseProcessor { if (!value.name || !value.slot || !theme) { return; } - const componentData = (theme as Theme).components?.[value.name]; + const componentData = theme.components?.[value.name]; if (!componentData) { return; } diff --git a/packages/zero-runtime/src/utils/cssFnValueToVariable.ts b/packages/zero-runtime/src/utils/cssFnValueToVariable.ts index 2e40a511bc6d78..d6642215e2511a 100644 --- a/packages/zero-runtime/src/utils/cssFnValueToVariable.ts +++ b/packages/zero-runtime/src/utils/cssFnValueToVariable.ts @@ -5,20 +5,17 @@ import * as t from '@babel/types'; import type { Expression } from '@babel/types'; import { isUnitLess } from './isUnitLess'; import { cssFunctionTransformerPlugin } from './cssFunctionTransformerPlugin'; +import type { Theme } from '../extendTheme'; interface StyleObj { [key: string]: string | number | (() => void) | StyleObj; } export type PluginCustomOptions = { - /** - * To generate css variables like this `--{cssVariablesPrefix}-palette-primary-main` - */ - cssVariablesPrefix?: string; /** * Object to pass as parameter to the styled css callback functions. */ - themeArgs?: Record; + themeArgs?: { theme?: Theme }; }; type CssFnValueToVariableParams = { diff --git a/packages/zero-runtime/src/utils/cssFunctionTransformerPlugin.ts b/packages/zero-runtime/src/utils/cssFunctionTransformerPlugin.ts index 30ef433de1813f..b5d5d4b8db2d2a 100644 --- a/packages/zero-runtime/src/utils/cssFunctionTransformerPlugin.ts +++ b/packages/zero-runtime/src/utils/cssFunctionTransformerPlugin.ts @@ -1,10 +1,8 @@ import { declare } from '@babel/helper-plugin-utils'; -import defaultSxConfig from '@mui/system/styleFunctionSx/defaultSxConfig'; +import { unstable_defaultSxConfig as defaultSxConfig } from '@mui/system/styleFunctionSx'; import get from 'lodash/get'; import type { PluginCustomOptions } from './cssFnValueToVariable'; -type Theme = { [key: 'unstable_sxConfig' | string]: string | number | Theme }; - type BabelPluginOptions = { styleKey: string; options: PluginCustomOptions; @@ -24,16 +22,13 @@ type BabelPluginOptions = { const cssFunctionTransformerPlugin = declare((api, pluginOptions) => { const { types: t } = api; const { - options: { cssVariablesPrefix = 'mui', themeArgs: { theme } = {} }, + options: { themeArgs: { theme } = {} }, styleKey, } = pluginOptions; - const typedTheme = theme as Theme & { - vars?: Theme; - }; - const config = (typedTheme?.unstable_sxConfig ?? defaultSxConfig) as Theme; - const cssPropOptions = config[styleKey] as Theme; + const config = theme?.unstable_sxConfig ?? defaultSxConfig; + const cssPropOptions = config[styleKey]; const themeKey = cssPropOptions?.themeKey; - const finalPrefix = cssVariablesPrefix ? `${cssVariablesPrefix}-` : ''; + const finalPrefix = theme?.cssVarPrefix || ''; return { name: '@mui/zero-internal/cssFunctionTransformerPlugin', @@ -50,8 +45,8 @@ const cssFunctionTransformerPlugin = declare((api, pluginOpt } const propertyThemeKey = themeKey ?? val.split('.')[0]; const themeValue = - get(typedTheme, `${propertyThemeKey}.${val}`) ?? - (typedTheme.vars ? get(typedTheme.vars, `${propertyThemeKey}.${val}`) : undefined); + get(theme, `${propertyThemeKey}.${val}`) ?? + (theme?.vars ? get(theme.vars, `${propertyThemeKey}.${val}`) : undefined); if (!themeValue) { console.warn( `MUI: Value for key: ${val} does not exist in "theme.${propertyThemeKey}" or "theme.vars.${propertyThemeKey}"`, diff --git a/packages/zero-runtime/src/utils/generateCss.ts b/packages/zero-runtime/src/utils/generateCss.ts index d585e7443ecd1f..08803856953dee 100644 --- a/packages/zero-runtime/src/utils/generateCss.ts +++ b/packages/zero-runtime/src/utils/generateCss.ts @@ -1,29 +1,39 @@ import { serializeStyles } from '@emotion/serialize'; +import { Theme } from '../extendTheme'; -type BaseTheme = { - vars?: Record; - cssVarPrefix: string; - colorSchemes: Record; - generateCssVars: (colorScheme?: string) => { css: Record }; -}; - -export function generateTokenCss(theme: BaseTheme) { +export function generateTokenCss(theme: Theme) { // create stylesheet as object - const stylesheetObj: Record> = { - ':root': theme.generateCssVars().css, - }; - Object.entries(theme.colorSchemes).forEach(([key]) => { - stylesheetObj[ - `${key === 'light' ? ':root, ' : ''}[data-${theme.cssVarPrefix}-color-scheme="${key}"]` - ] = theme.generateCssVars(key).css; - }); + const { css: rootCss, selector: rootSelector } = theme.generateCssVars(); + const stylesheets: Array> = []; + if (Object.keys(rootCss).length) { + stylesheets.push(typeof rootSelector === 'string' ? { [rootSelector]: rootCss } : rootSelector); + } + if (theme.colorSchemes) { + const { [theme.defaultColorScheme!]: defaultScheme, ...otherColorSchemes } = theme.colorSchemes; + + if (defaultScheme) { + // need to generate default color scheme first for the prefers-color-scheme media query to work + // because media-queries does not increase specificity + const { css, selector } = theme.generateCssVars(theme.defaultColorScheme); + if (Object.keys(css).length) { + stylesheets.push(typeof selector === 'string' ? { [selector]: css } : selector); + } + } + + Object.entries(otherColorSchemes).forEach(([key]) => { + const { css, selector } = theme.generateCssVars(key); + if (Object.keys(css).length) { + stylesheets.push(typeof selector === 'string' ? { [selector]: css } : selector); + } + }); + } // use emotion to serialize the object to css string - const { styles } = serializeStyles([stylesheetObj]); + const { styles } = serializeStyles(stylesheets); return styles; } -export function generateThemeTokens(theme: BaseTheme) { +export function generateThemeTokens(theme: Theme) { if (!theme || typeof theme !== 'object') { return {}; } diff --git a/packages/zero-runtime/tsconfig.json b/packages/zero-runtime/tsconfig.json index 863e932e7ce5cc..1e4e9fefe33535 100644 --- a/packages/zero-runtime/tsconfig.json +++ b/packages/zero-runtime/tsconfig.json @@ -6,7 +6,13 @@ "lib": ["ES2017", "ES2021.String", "DOM"], "composite": true, "noEmit": false, - "resolveJsonModule": true + "resolveJsonModule": true, + "paths": { + "@mui/system": ["./packages/mui-system/src"], + "@mui/system/*": ["./packages/mui-system/src/*"], + "@mui/utils": ["./packages/mui-utils/src"], + "@mui/utils/*": ["./packages/mui-utils/src/*"] + } }, "include": ["src/**/*.ts"], "exclude": ["./tsup.config.ts"] diff --git a/packages/zero-unplugin/src/index.ts b/packages/zero-unplugin/src/index.ts index 3958342f8c6f61..8fbecbab554766 100644 --- a/packages/zero-unplugin/src/index.ts +++ b/packages/zero-unplugin/src/index.ts @@ -19,6 +19,7 @@ import { generateTokenCss, generateThemeTokens, } from '@mui/zero-runtime/utils'; +import type { Theme as BaseTheme } from '@mui/zero-runtime'; type NextMeta = { type: 'next'; @@ -38,14 +39,8 @@ type WebpackMeta = { type Meta = NextMeta | ViteMeta | WebpackMeta; -type BaseTheme = { - cssVarPrefix: string; - colorSchemes: Record; - generateCssVars: (colorScheme?: string) => { css: Record }; -}; - export type PluginOptions = { - theme: Theme; + theme?: Theme; transformLibraries?: string[]; preprocessor?: Preprocessor; debug?: IFileReporterOptions | false; @@ -165,7 +160,6 @@ export const plugin = createUnplugin((options) => { themeArgs: { theme, }, - cssVariablesPrefix: theme.cssVarPrefix, overrideContext(context: Record, filename: string) { if (overrideContext) { return overrideContext(context, filename); @@ -280,10 +274,12 @@ export const plugin = createUnplugin((options) => { }, transform(_code, id) { if (id.endsWith('styles.css')) { - return generateTokenCss(theme); + return theme ? generateTokenCss(theme) : _code; } if (id.includes('zero-runtime/theme')) { - return `export default ${JSON.stringify(generateThemeTokens(theme))};`; + return `export default ${ + theme ? JSON.stringify(generateThemeTokens(theme)) : '{}' + };`; } return null; }, @@ -302,11 +298,13 @@ export const plugin = createUnplugin((options) => { return isZeroRuntimeThemeFile(id); }, load(id) { - if (id === VIRTUAL_CSS_FILE) { + if (id === VIRTUAL_CSS_FILE && theme) { return generateTokenCss(theme); } if (id === VIRTUAL_THEME_FILE) { - return `export default ${JSON.stringify(generateThemeTokens(theme))};`; + return `export default ${ + theme ? JSON.stringify(generateThemeTokens(theme)) : '{}' + };`; } return null; }, diff --git a/packages/zero-unplugin/tsconfig.build.json b/packages/zero-unplugin/tsconfig.build.json index 80b6a0a8461245..9bb90e12c7f860 100644 --- a/packages/zero-unplugin/tsconfig.build.json +++ b/packages/zero-unplugin/tsconfig.build.json @@ -1,6 +1,9 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "composite": false + "composite": false, + "paths": { + "@babel/core": ["./node_modules/@babel/core"] + } } } diff --git a/packages/zero-unplugin/tsconfig.json b/packages/zero-unplugin/tsconfig.json index 16edb79b6bbcfc..b0083e731e2538 100644 --- a/packages/zero-unplugin/tsconfig.json +++ b/packages/zero-unplugin/tsconfig.json @@ -4,7 +4,13 @@ "resolveJsonModule": true, "target": "ES2022", "paths": { - "@babel/core": ["./node_modules/@babel/core"] + "@babel/core": ["./node_modules/@babel/core"], + "@mui/system": ["./packages/mui-system/src"], + "@mui/system/*": ["./packages/mui-system/src/*"], + "@mui/utils": ["./packages/mui-utils/src"], + "@mui/utils/*": ["./packages/mui-utils/src/*"], + "@mui/zero-runtime": ["./packages/zero-runtime/src"], + "@mui/zero-runtime/*": ["./packages/zero-runtime/src/*"] } }, "include": ["src/**/*.ts"], diff --git a/packages/zero-vite-plugin/src/index.ts b/packages/zero-vite-plugin/src/index.ts index aef0933c855c65..f494274b65a4d4 100644 --- a/packages/zero-vite-plugin/src/index.ts +++ b/packages/zero-vite-plugin/src/index.ts @@ -4,24 +4,15 @@ import { generateTokenCss, generateThemeTokens, } from '@mui/zero-runtime/utils'; +import type { Theme } from '@mui/zero-runtime'; import { transformAsync } from '@babel/core'; import baseZeroVitePlugin, { type VitePluginOptions } from './zero-vite-plugin'; -interface BaseTheme { - cssVarPrefix: string; - colorSchemes: Record; - generateCssVars: (colorScheme?: string) => { css: Record }; -} - export interface ZeroVitePluginOptions extends VitePluginOptions { /** * The theme object that you want to be passed to the `styled` function */ - theme: unknown; - /** - * Prefix string to use in the generated css variables. - */ - cssVariablesPrefix?: string; + theme: Theme; /** * Whether the css variables for the default theme should target the :root selector or not. * @default true @@ -73,10 +64,10 @@ export function zeroVitePlugin(options: ZeroVitePluginOptions) { }, load(id) { if (id === VIRTUAL_CSS_FILE) { - return generateTokenCss(theme as BaseTheme); + return generateTokenCss(theme); } if (id === VIRTUAL_THEME_FILE) { - return `export default ${JSON.stringify(generateThemeTokens(theme as BaseTheme))};`; + return `export default ${JSON.stringify(generateThemeTokens(theme))};`; } return null; }, @@ -112,7 +103,6 @@ export function zeroVitePlugin(options: ZeroVitePluginOptions) { } const zeroPlugin = baseZeroVitePlugin({ - cssVariablesPrefix: (theme as BaseTheme).cssVarPrefix, themeArgs: { theme, }, diff --git a/packages/zero-vite-plugin/tsconfig.build.json b/packages/zero-vite-plugin/tsconfig.build.json index 80b6a0a8461245..9bb90e12c7f860 100644 --- a/packages/zero-vite-plugin/tsconfig.build.json +++ b/packages/zero-vite-plugin/tsconfig.build.json @@ -1,6 +1,9 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "composite": false + "composite": false, + "paths": { + "@babel/core": ["./node_modules/@babel/core"] + } } } diff --git a/packages/zero-vite-plugin/tsconfig.json b/packages/zero-vite-plugin/tsconfig.json index 819ce7d6a1f66a..72c0ce02c10646 100644 --- a/packages/zero-vite-plugin/tsconfig.json +++ b/packages/zero-vite-plugin/tsconfig.json @@ -1,10 +1,17 @@ { "extends": "../../tsconfig.json", - "include": ["src/**/*"], "compilerOptions": { "target": "ES2022", "paths": { - "@babel/core": ["./node_modules/@babel/core"] + "@babel/core": ["./node_modules/@babel/core"], + "@mui/system": ["./packages/mui-system/src"], + "@mui/system/*": ["./packages/mui-system/src/*"], + "@mui/utils": ["./packages/mui-utils/src"], + "@mui/utils/*": ["./packages/mui-utils/src/*"], + "@mui/zero-runtime": ["./packages/zero-runtime/src"], + "@mui/zero-runtime/*": ["./packages/zero-runtime/src/*"] } - } + }, + "include": ["src/**/*"], + "exclude": ["./tsup.config.ts"] }