diff --git a/docs/data/material/integrations/tailwindcss/TailwindCard.js b/docs/data/material/integrations/tailwindcss/TailwindCard.js new file mode 100644 index 00000000000000..a1201ec17ccf33 --- /dev/null +++ b/docs/data/material/integrations/tailwindcss/TailwindCard.js @@ -0,0 +1,105 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import CardActions from '@mui/material/CardActions'; +import Button from '@mui/material/Button'; +import Chip from '@mui/material/Chip'; +import Avatar from '@mui/material/Avatar'; +import AvatarGroup from '@mui/material/AvatarGroup'; + +function JobCard({ jobTitle, team, status, applicants, avatarColors }) { + const isActive = status === 'Active'; + return ( + + +
+
+
+ {jobTitle[0]} +
+
+

+ {jobTitle} +

+

+ {team} +

+
+
+ +
+
+
+ + {avatarColors.map((color, i) => ( + + ))} + + + {applicants} applicants + +
+ + Remote + +
+
+ + + + +
+ ); +} + +JobCard.propTypes = { + applicants: PropTypes.number.isRequired, + avatarColors: PropTypes.arrayOf(PropTypes.string).isRequired, + jobTitle: PropTypes.string.isRequired, + status: PropTypes.oneOf(['Active', 'Pending']).isRequired, + team: PropTypes.string.isRequired, +}; + +export default function TailwindCard() { + return ( +
+ + +
+ ); +} diff --git a/docs/data/material/integrations/tailwindcss/TailwindCard.tsx b/docs/data/material/integrations/tailwindcss/TailwindCard.tsx new file mode 100644 index 00000000000000..28db04c4403325 --- /dev/null +++ b/docs/data/material/integrations/tailwindcss/TailwindCard.tsx @@ -0,0 +1,110 @@ +import * as React from 'react'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import CardActions from '@mui/material/CardActions'; +import Button from '@mui/material/Button'; +import Chip from '@mui/material/Chip'; +import Avatar from '@mui/material/Avatar'; +import AvatarGroup from '@mui/material/AvatarGroup'; + +interface JobCardProps { + jobTitle: string; + team: string; + status: 'Active' | 'Pending'; + applicants: number; + avatarColors: string[]; +} + +function JobCard({ + jobTitle, + team, + status, + applicants, + avatarColors, +}: JobCardProps) { + const isActive = status === 'Active'; + return ( + + +
+
+
+ {jobTitle[0]} +
+
+

+ {jobTitle} +

+

+ {team} +

+
+
+ +
+
+
+ + {avatarColors.map((color, i) => ( + + ))} + + + {applicants} applicants + +
+ + Remote + +
+
+ + + + +
+ ); +} + +export default function TailwindCard() { + return ( +
+ + +
+ ); +} diff --git a/docs/data/material/integrations/tailwindcss/TailwindCard.tsx.preview b/docs/data/material/integrations/tailwindcss/TailwindCard.tsx.preview new file mode 100644 index 00000000000000..cf198deb4e2606 --- /dev/null +++ b/docs/data/material/integrations/tailwindcss/TailwindCard.tsx.preview @@ -0,0 +1,14 @@ + + \ No newline at end of file diff --git a/docs/data/material/integrations/tailwindcss/TailwindDisabledState.js b/docs/data/material/integrations/tailwindcss/TailwindDisabledState.js new file mode 100644 index 00000000000000..7f47b691debe68 --- /dev/null +++ b/docs/data/material/integrations/tailwindcss/TailwindDisabledState.js @@ -0,0 +1,45 @@ +import * as React from 'react'; +import Slider from '@mui/material/Slider'; +import Switch from '@mui/material/Switch'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Stack from '@mui/material/Stack'; + +export default function TailwindDisabledState() { + return ( + +
+

+ Volume — enabled vs disabled +

+ + {/* Tailwind's `mui-disabled:` variant adds opacity only when the + component has the `Mui-disabled` class applied by MUI */} + + + +
+
+

+ Notifications — enabled vs disabled +

+ + } + /> + } + /> + +
+
+ ); +} diff --git a/docs/data/material/integrations/tailwindcss/TailwindDisabledState.tsx b/docs/data/material/integrations/tailwindcss/TailwindDisabledState.tsx new file mode 100644 index 00000000000000..b2d80625f31186 --- /dev/null +++ b/docs/data/material/integrations/tailwindcss/TailwindDisabledState.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import Slider from '@mui/material/Slider'; +import Switch from '@mui/material/Switch'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Stack from '@mui/material/Stack'; + +export default function TailwindDisabledState() { + return ( + +
+

+ Volume — enabled vs disabled +

+ + {/* Tailwind's `mui-disabled:` variant adds opacity only when the + component has the `Mui-disabled` class applied by MUI */} + + + +
+
+

+ Notifications — enabled vs disabled +

+ + } + /> + } + /> + +
+
+ ); +} diff --git a/docs/data/material/integrations/tailwindcss/TailwindFilterChips.js b/docs/data/material/integrations/tailwindcss/TailwindFilterChips.js new file mode 100644 index 00000000000000..81e08d8cd930b2 --- /dev/null +++ b/docs/data/material/integrations/tailwindcss/TailwindFilterChips.js @@ -0,0 +1,46 @@ +import * as React from 'react'; +import ToggleButton from '@mui/material/ToggleButton'; +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; + +const tags = ['React', 'TypeScript', 'CSS', 'Performance', 'Testing']; + +export default function TailwindFilterChips() { + const [selected, setSelected] = React.useState(['React', 'TypeScript']); + + const handleChange = (_, newSelected) => { + setSelected(newSelected); + }; + + return ( +
+ + {tags.map((tag) => ( + + {tag} + + ))} + +

+ {selected.length === 0 + ? 'No filters active' + : `Active: ${selected.join(', ')}`} +

+
+ ); +} diff --git a/docs/data/material/integrations/tailwindcss/TailwindFilterChips.tsx b/docs/data/material/integrations/tailwindcss/TailwindFilterChips.tsx new file mode 100644 index 00000000000000..9ad5eb006faf0c --- /dev/null +++ b/docs/data/material/integrations/tailwindcss/TailwindFilterChips.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import ToggleButton from '@mui/material/ToggleButton'; +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; + +const tags = ['React', 'TypeScript', 'CSS', 'Performance', 'Testing']; + +export default function TailwindFilterChips() { + const [selected, setSelected] = React.useState(['React', 'TypeScript']); + + const handleChange = (_: React.MouseEvent, newSelected: string[]) => { + setSelected(newSelected); + }; + + return ( +
+ + {tags.map((tag) => ( + + {tag} + + ))} + +

+ {selected.length === 0 + ? 'No filters active' + : `Active: ${selected.join(', ')}`} +

+
+ ); +} diff --git a/docs/data/material/integrations/tailwindcss/tailwindcss-components.md b/docs/data/material/integrations/tailwindcss/tailwindcss-components.md new file mode 100644 index 00000000000000..5db060463c6b9a --- /dev/null +++ b/docs/data/material/integrations/tailwindcss/tailwindcss-components.md @@ -0,0 +1,132 @@ +# Using the @mui/tailwind package + +

The @mui/tailwind package provides a Tailwind CSS preset, plugin, and v4 CSS file that wire MUI's design tokens and component state classes into Tailwind.

+ +## Installation + +```bash +npm install @mui/tailwind +``` + +## Setup + +### Tailwind v4 + +Import the CSS file in your global stylesheet after your `@import "tailwindcss"` line: + +```css title="global.css" +@layer theme, base, mui.default, components, utilities; +@import 'tailwindcss'; +@import '@mui/tailwind/v4.css'; +``` + +`v4.css` adds three things: + +1. **Token mapping** — maps `--mui-palette-*`, `--mui-shadows-*`, and other MUI CSS variables to Tailwind theme tokens so utilities like `text-primary`, `bg-error/20`, and `shadow-8` work out of the box (requires `CssVarsProvider`) +2. **State variants** — adds `mui-{state}:` and `mui-not-{state}:` modifier classes for every MUI component state class +3. **MUI-aligned breakpoints** — sets `sm=600px`, `md=900px`, `lg=1200px`, `xl=1536px` + +### Tailwind v3 + +Add the preset to `tailwind.config.js`: + +```js title="tailwind.config.js" +module.exports = { + presets: [require('@mui/tailwind')], +}; +``` + +The preset extends the Tailwind theme with MUI color tokens (using channel variables for opacity modifier support) and includes the plugin automatically. + +If you only need the state variant plugin without the full preset: + +```js title="tailwind.config.js" +module.exports = { + plugins: [require('@mui/tailwind/plugin')], +}; +``` + +## State variants + +The `@mui/tailwind` plugin adds `mui-{state}:` modifier classes that target MUI component state classes (`Mui-disabled`, `Mui-selected`, `Mui-error`, etc.). + +| Variant | Targets | +| :------------------- | :------------------ | +| `mui-disabled:` | `.Mui-disabled` | +| `mui-selected:` | `.Mui-selected` | +| `mui-error:` | `.Mui-error` | +| `mui-focused:` | `.Mui-focused` | +| `mui-focus-visible:` | `.Mui-focusVisible` | +| `mui-checked:` | `.Mui-checked` | +| `mui-expanded:` | `.Mui-expanded` | +| `mui-active:` | `.Mui-active` | +| `mui-readonly:` | `.Mui-readOnly` | +| `mui-required:` | `.Mui-required` | +| `mui-completed:` | `.Mui-completed` | + +Every variant has a `mui-not-{state}:` counterpart that matches when the class is absent. + +## Viewing the demos + +Start the docs dev server: + +```bash +pnpm docs:dev +``` + +Then open: http://localhost:3000/material-ui/integrations/tailwindcss/tailwindcss-components/ + +### Filter chips with `mui-selected` + +The following demo shows `mui-selected:` styling applied to a `ToggleButtonGroup`. +MUI adds the `Mui-selected` class to each `ToggleButton` when it is active, +and the Tailwind variant applies the indigo styles only to those elements. + +{{"demo": "TailwindFilterChips.js"}} + +### Disabled state with `mui-disabled` + +`mui-disabled:` targets elements where MUI has applied the `Mui-disabled` class. +Because the variant selector is specific to the disabled state, the modifier +applies only to the disabled component, leaving the enabled one untouched. + +{{"demo": "TailwindDisabledState.js"}} + +## Styling components with `className` and `slotProps` + +Use `className` to apply Tailwind utilities to a component's root element. +Use `slotProps.{slot}.className` to reach interior elements. + +The following demo builds a job-listing card using only Tailwind utilities +for visual design while keeping MUI components for structure and accessibility. + +{{"demo": "TailwindCard.js"}} + +## Layer ordering + +For Tailwind utilities to override MUI component styles **without `!important`**, MUI's styles must be inside a CSS layer with lower priority than Tailwind's `utilities` layer. + +With the Emotion-based setup, enable `enableCssLayer` to put MUI styles in `@layer mui`: + +```css title="global.css" +@layer theme, base, mui, components, utilities; +@import 'tailwindcss'; +``` + +```tsx title="app/layout.tsx (Next.js App Router)" +import { AppRouterCacheProvider } from '@mui/material-nextjs/v15-appRouter'; + +export default function RootLayout({ children }) { + return ( + + + + {children} + + + + ); +} +``` + +With the CSS-based setup (`@mui/styled-engine-noop`), MUI component styles are already in `@layer mui.default`, so the layer ordering is automatic — Tailwind utilities win without any `!important`. diff --git a/docs/data/material/integrations/tailwindcss/tailwindcss-v4.md b/docs/data/material/integrations/tailwindcss/tailwindcss-v4.md index 5e71ba7454ed5e..53168d1ed0e5e5 100644 --- a/docs/data/material/integrations/tailwindcss/tailwindcss-v4.md +++ b/docs/data/material/integrations/tailwindcss/tailwindcss-v4.md @@ -151,6 +151,10 @@ Now you should see the autocomplete and syntax highlighting features when using ## Extend Material UI classes +:::info +Instead of copying this snippet, install the [`@mui/tailwind`](/material-ui/integrations/tailwindcss/tailwindcss-components/) package which ships the same token mapping alongside MUI state variants (`mui-disabled:`, `mui-selected:`, and more) and MUI-aligned breakpoints in a single import. +::: + If you want to use Material UI theme tokens in your Tailwind CSS classes, copy the snippet below into your CSS file. ```css title="global.css" diff --git a/docs/package.json b/docs/package.json index f1df7b7817fdd9..106827e4ba04bc 100644 --- a/docs/package.json +++ b/docs/package.json @@ -35,6 +35,7 @@ "@mui/styled-engine-sc": "workspace:^", "@mui/stylis-plugin-rtl": "workspace:^", "@mui/system": "workspace:^", + "@mui/tailwind": "workspace:^", "@mui/types": "workspace:^", "@mui/utils": "workspace:^", "@mui/x-charts": "9.5.0", @@ -75,7 +76,6 @@ "notistack": "3.0.2", "nprogress": "^0.2.0", "postcss": "^8.5.14", - "postcss-import": "^16.1.1", "prop-types": "^15.8.1", "react": "^19.2.6", "react-dom": "^19.2.6", diff --git a/docs/pages/experiments/css-slider.tsx b/docs/pages/experiments/css-slider.tsx new file mode 100644 index 00000000000000..18514d1a3d9c40 --- /dev/null +++ b/docs/pages/experiments/css-slider.tsx @@ -0,0 +1,192 @@ +'use client'; +import * as React from 'react'; +import { createTheme, CssThemeProvider, styled, useCssColorScheme } from '@mui/material/styles'; +import Slider from '@mui/material/Slider'; + +// ---------- Themes ---------------------------------------------------------- + +const themes = { + violet: createTheme({ + cssVariables: { colorSchemeSelector: 'data' }, + colorSchemes: { + light: { palette: { primary: { main: '#7c3aed' }, secondary: { main: '#db2777' } } }, + dark: { palette: { primary: { main: '#a78bfa' }, secondary: { main: '#f472b6' } } }, + }, + shape: { borderRadius: 12 }, + }), + teal: createTheme({ + cssVariables: { colorSchemeSelector: 'data' }, + colorSchemes: { + light: { palette: { primary: { main: '#0d9488' }, secondary: { main: '#f59e0b' } } }, + dark: { palette: { primary: { main: '#2dd4bf' }, secondary: { main: '#fbbf24' } } }, + }, + shape: { borderRadius: 4 }, + components: { + MuiSlider: { + styleOverrides: { + thumb: { width: 24, height: 24 }, + track: { height: 6 }, + rail: { height: 6 }, + }, + }, + }, + }), + blue: createTheme({ + cssVariables: { colorSchemeSelector: 'data' }, + colorSchemes: { + light: { palette: { primary: { main: '#1d4ed8' }, secondary: { main: '#dc2626' } } }, + dark: { palette: { primary: { main: '#60a5fa' }, secondary: { main: '#f87171' } } }, + }, + shape: { borderRadius: 8 }, + components: { + MuiSlider: { + defaultProps: { size: 'small' }, + }, + }, + }), +} as const; + +type ThemeKey = keyof typeof themes; + +// ---------- styled() example ------------------------------------------------ +// CssThemeProvider does not inject an Emotion ThemeContext, so CSS variables +// are used directly in place of theme token references. + +const GradientSlider = styled(Slider)({ + '& .MuiSlider-track': { + background: `linear-gradient(90deg, var(--mui-palette-primary-main), var(--mui-palette-secondary-main))`, + border: 'none', + }, + '& .MuiSlider-thumb': { + background: 'var(--mui-palette-secondary-main)', + }, +}); + +// ---------- useMounted guard (avoid SSR hydration mismatch) ---------------- + +function useMounted() { + const [mounted, setMounted] = React.useState(false); + React.useEffect(() => setMounted(true), []); + return mounted; +} + +// ---------- Page ------------------------------------------------------------ + +function Page({ + themeKey, + setThemeKey, +}: { + themeKey: ThemeKey; + setThemeKey: (key: ThemeKey) => void; +}) { + const { mode, setMode } = useCssColorScheme(); + const mounted = useMounted(); + const [value1, setValue1] = React.useState(60); + const [value2, setValue2] = React.useState(40); + const [value3, setValue3] = React.useState(30); + + return ( +
+

CssThemeProvider + Slider

+

+ Verifies Slider works with the CSS-only path. Engine: @mui/styled-engine-noop — + no Emotion. +

+ + {/* Theme switcher */} +
+ {(Object.keys(themes) as ThemeKey[]).map((key) => ( + + ))} +
+ + {/* Dark mode toggle */} + + + {/* ── sx prop ──────────────────────────────────────────────────── */} +

sx prop — secondary color + custom thumb:

+
+ setValue1(v as number)} + sx={{ + color: 'var(--mui-palette-secondary-main)', + '& .MuiSlider-thumb': { width: 22, height: 22 }, + }} + aria-label="sx slider" + /> +
+

Value: {value1}

+ + {/* ── className + style tag ─────────────────────────────────────── */} +

+ className override — thumb should be purple via plain CSS: +

+
+ setValue2(v as number)} + className="css-custom-slider" + aria-label="className slider" + /> +
+

Value: {value2}

+ + {/* ── styled() ────────────────────────────────────────────────── */} +

styled() — gradient track:

+
+ setValue3(v as number)} + aria-label="gradient slider" + /> +
+

Value: {value3}

+ + +
+ ); +} + +export default function CssSlider() { + const [themeKey, setThemeKey] = React.useState('violet'); + return ( + + + + ); +} diff --git a/docs/pages/experiments/emotion-slider.tsx b/docs/pages/experiments/emotion-slider.tsx new file mode 100644 index 00000000000000..37f0fc2efe846c --- /dev/null +++ b/docs/pages/experiments/emotion-slider.tsx @@ -0,0 +1,190 @@ +'use client'; +import * as React from 'react'; +import { createTheme, ThemeProvider, styled, useColorScheme } from '@mui/material/styles'; +import Slider from '@mui/material/Slider'; + +// ---------- Themes ---------------------------------------------------------- + +const themes = { + violet: createTheme({ + cssVariables: { colorSchemeSelector: 'data' }, + colorSchemes: { + light: { palette: { primary: { main: '#7c3aed' }, secondary: { main: '#db2777' } } }, + dark: { palette: { primary: { main: '#a78bfa' }, secondary: { main: '#f472b6' } } }, + }, + shape: { borderRadius: 12 }, + }), + teal: createTheme({ + cssVariables: { colorSchemeSelector: 'data' }, + colorSchemes: { + light: { palette: { primary: { main: '#0d9488' }, secondary: { main: '#f59e0b' } } }, + dark: { palette: { primary: { main: '#2dd4bf' }, secondary: { main: '#fbbf24' } } }, + }, + shape: { borderRadius: 4 }, + components: { + MuiSlider: { + styleOverrides: { + thumb: { width: 24, height: 24 }, + track: { height: 6 }, + rail: { height: 6 }, + }, + }, + }, + }), + blue: createTheme({ + cssVariables: { colorSchemeSelector: 'data' }, + colorSchemes: { + light: { palette: { primary: { main: '#1d4ed8' }, secondary: { main: '#dc2626' } } }, + dark: { palette: { primary: { main: '#60a5fa' }, secondary: { main: '#f87171' } } }, + }, + shape: { borderRadius: 8 }, + components: { + MuiSlider: { + defaultProps: { size: 'small' }, + }, + }, + }), +} as const; + +type ThemeKey = keyof typeof themes; + +// ---------- styled() example ------------------------------------------------ + +const GradientSlider = styled(Slider)(({ theme }) => ({ + '& .MuiSlider-track': { + background: `linear-gradient(90deg, ${theme.palette.primary.main}, ${theme.palette.secondary.main})`, + border: 'none', + }, + '& .MuiSlider-thumb': { + background: theme.palette.secondary.main, + }, +})); + +// ---------- useMounted guard (avoid SSR hydration mismatch) ---------------- + +function useMounted() { + const [mounted, setMounted] = React.useState(false); + React.useEffect(() => setMounted(true), []); + return mounted; +} + +// ---------- Page ------------------------------------------------------------ + +function Page({ + themeKey, + setThemeKey, +}: { + themeKey: ThemeKey; + setThemeKey: (key: ThemeKey) => void; +}) { + const { mode, setMode } = useColorScheme(); + const mounted = useMounted(); + const [value1, setValue1] = React.useState(60); + const [value2, setValue2] = React.useState(40); + const [value3, setValue3] = React.useState(30); + + return ( +
+

Emotion + Slider

+

+ Verifies Slider works with the Emotion path. Engine: @mui/styled-engine{' '} + (Emotion). +

+ + {/* Theme switcher */} +
+ {(Object.keys(themes) as ThemeKey[]).map((key) => ( + + ))} +
+ + {/* Dark mode toggle */} + + + {/* ── sx prop ──────────────────────────────────────────────────── */} +

sx prop — secondary color + custom thumb:

+
+ setValue1(v as number)} + sx={{ + color: 'secondary.main', + '& .MuiSlider-thumb': { width: 22, height: 22 }, + }} + aria-label="sx slider" + /> +
+

Value: {value1}

+ + {/* ── className + style tag ─────────────────────────────────────── */} +

+ className override — thumb should be purple via plain CSS: +

+
+ setValue2(v as number)} + className="emotion-custom-slider" + aria-label="className slider" + /> +
+

Value: {value2}

+ + {/* ── styled() ────────────────────────────────────────────────── */} +

styled() — gradient track:

+
+ setValue3(v as number)} + aria-label="gradient slider" + /> +
+

Value: {value3}

+ + +
+ ); +} + +export default function EmotionSlider() { + const [themeKey, setThemeKey] = React.useState('violet'); + return ( + + + + ); +} diff --git a/docs/pages/global.css b/docs/pages/global.css index 2e7ff043d25b9f..41dcae1c8fd3fd 100644 --- a/docs/pages/global.css +++ b/docs/pages/global.css @@ -1,3 +1,7 @@ +@layer theme, base, mui.default, components, utilities; + @import 'tailwindcss/theme.css' layer(theme); @import 'tailwindcss/utilities.css' layer(utilities); +@import '@mui/tailwind/v4.css'; + @config '../tailwind.config.mjs'; diff --git a/docs/pages/material-ui/integrations/tailwindcss/tailwindcss-components.js b/docs/pages/material-ui/integrations/tailwindcss/tailwindcss-components.js new file mode 100644 index 00000000000000..596e915c6d2a35 --- /dev/null +++ b/docs/pages/material-ui/integrations/tailwindcss/tailwindcss-components.js @@ -0,0 +1,6 @@ +import { MarkdownDocs } from '@mui/internal-core-docs/MarkdownDocs'; +import * as pageProps from 'docs/data/material/integrations/tailwindcss/tailwindcss-components.md?muiMarkdown'; + +export default function Page() { + return ; +} diff --git a/docs/postcss.config.js b/docs/postcss.config.js index ac6e4da4cd6a1c..e5640725a9e329 100644 --- a/docs/postcss.config.js +++ b/docs/postcss.config.js @@ -1,6 +1,5 @@ module.exports = { plugins: { - 'postcss-import': {}, '@tailwindcss/postcss': {}, }, }; diff --git a/docs/public/static/error-codes.json b/docs/public/static/error-codes.json index 1c94b8d8935583..178303977c3c58 100644 --- a/docs/public/static/error-codes.json +++ b/docs/public/static/error-codes.json @@ -18,7 +18,9 @@ "17": "MUI: Expected valid input target. Did you use a custom `slots.input` and forget to forward refs? See https://mui.com/r/input-component-ref-interface for more info.", "18": "MUI: The provided shorthand %s is invalid. The format should be `@` or `@/`.\nFor example, `@sm` or `@600` or `@40rem/sidebar`.", "19": "MUI: The `experimental_sx` has been moved to `theme.unstable_sx`.For more details, see https://github.com/mui/material-ui/pull/35150.", - "20": "MUI: `vars` is a private field used for CSS variables support.\nPlease use another name or follow the [docs](https://mui.com/material-ui/customization/css-theme-variables/usage/) to enable the feature.", "21": "MUI: The `colorSchemes.%s` option is either missing or invalid.", - "22": "MUI: `vars` is a private field used for CSS variables support.\nPlease use another name or follow the [docs](https://mui.com/material-ui/customization/css-theme-variables/usage/) to enable the feature." + "22": "MUI: `vars` is a private field used for CSS variables support.\nPlease use another name or follow the [docs](https://mui.com/material-ui/customization/css-theme-variables/usage/) to enable the feature.", + "23": "MUI: `useCssColorScheme` must be called inside a `CssThemeProvider`. See https://mui.com/r/css-theme-provider for more info.", + "24": "MUI: CssThemeProvider does not accept a theme function. Pass a theme object created with `createTheme({ cssVariables: true })` instead. See https://mui.com/r/css-theme-provider for more info.", + "25": "MUI: CssThemeProvider requires a theme created with `cssVariables: true`. Without it, the imported component CSS files reference variables that are never generated, leaving components unstyled. See https://mui.com/r/css-theme-provider for more info." } diff --git a/packages/mui-material/package.json b/packages/mui-material/package.json index 3692d62f304084..152c352fce21fd 100644 --- a/packages/mui-material/package.json +++ b/packages/mui-material/package.json @@ -25,7 +25,7 @@ "url": "https://opencollective.com/mui-org" }, "scripts": { - "build": "code-infra build --tsgo --flat", + "build": "code-infra build --tsgo --flat --copy \"src/**/*.css\"", "release": "pnpm build && pnpm publish", "test": "pnpm --workspace-root test:unit --project \"*:@mui/material\"", "typescript": "tsgo -p tsconfig.json", @@ -83,7 +83,9 @@ "optional": true } }, - "sideEffects": false, + "sideEffects": [ + "*.css" + ], "publishConfig": { "access": "public", "directory": "build" diff --git a/packages/mui-material/src/Slider/Slider.js b/packages/mui-material/src/Slider/Slider.js index bdc7e2c3d992e5..2f1a80612f0b97 100644 --- a/packages/mui-material/src/Slider/Slider.js +++ b/packages/mui-material/src/Slider/Slider.js @@ -8,15 +8,13 @@ import { useRtl } from '@mui/system/RtlProvider'; import isHostComponent from '@mui/utils/isHostComponent'; import { useSlider, valueToPercent } from './useSlider'; import { styled } from '../zero-styled'; -import memoTheme from '../utils/memoTheme'; import { useDefaultProps } from '../DefaultPropsProvider'; import useSlot from '../utils/useSlot'; import slotShouldForwardProp from '../styles/slotShouldForwardProp'; import capitalize from '../utils/capitalize'; -import createSimplePaletteValueFilter from '../utils/createSimplePaletteValueFilter'; import BaseSliderValueLabel from './SliderValueLabel'; -import sliderClasses, { getSliderUtilityClass } from './sliderClasses'; -import { getTransitionStyles } from '../transitions/utils'; +import { getSliderUtilityClass } from './sliderClasses'; +import cssModules from './Slider.module.css'; function Identity(x) { return x; @@ -38,374 +36,35 @@ export const SliderRoot = styled('span', { ownerState.track === false && styles.trackFalse, ]; }, -})( - memoTheme(({ theme }) => ({ - borderRadius: 12, - boxSizing: 'content-box', - display: 'inline-block', - position: 'relative', - cursor: 'pointer', - touchAction: 'none', - WebkitTapHighlightColor: 'transparent', - '@media print': { - colorAdjust: 'exact', - }, - [`&.${sliderClasses.disabled}`]: { - pointerEvents: 'none', - cursor: 'default', - color: (theme.vars || theme).palette.grey[400], - }, - [`&.${sliderClasses.dragging}`]: { - [`& .${sliderClasses.thumb}, & .${sliderClasses.track}`]: { - transition: 'none', - }, - }, - variants: [ - ...Object.entries(theme.palette) - .filter(createSimplePaletteValueFilter()) - .map(([color]) => ({ - props: { color }, - style: { - color: (theme.vars || theme).palette[color].main, - }, - })), - { - props: { orientation: 'horizontal' }, - style: { - height: 4, - width: '100%', - padding: '13px 0', - // The primary input mechanism of the device includes a pointing device of limited accuracy. - '@media (pointer: coarse)': { - // Reach 42px touch target, about ~8mm on screen. - padding: '20px 0', - }, - }, - }, - { - props: { orientation: 'horizontal', size: 'small' }, - style: { - height: 2, - }, - }, - { - props: { orientation: 'horizontal', marked: true }, - style: { - marginBottom: 20, - }, - }, - { - props: { orientation: 'vertical' }, - style: { - height: '100%', - width: 4, - padding: '0 13px', - // The primary input mechanism of the device includes a pointing device of limited accuracy. - '@media (pointer: coarse)': { - // Reach 42px touch target, about ~8mm on screen. - padding: '0 20px', - }, - }, - }, - { - props: { orientation: 'vertical', size: 'small' }, - style: { - width: 2, - }, - }, - { - props: { orientation: 'vertical', marked: true }, - style: { - marginRight: 44, - }, - }, - ], - })), -); +})({}); export const SliderRail = styled('span', { name: 'MuiSlider', slot: 'Rail', -})({ - display: 'block', - position: 'absolute', - borderRadius: 'inherit', - backgroundColor: 'currentColor', - opacity: 0.38, - '@media (forced-colors: active)': { - border: '1px solid transparent', - boxSizing: 'border-box', - }, - variants: [ - { - props: { orientation: 'horizontal' }, - style: { - width: '100%', - height: 'inherit', - top: '50%', - transform: 'translateY(-50%)', - }, - }, - { - props: { orientation: 'vertical' }, - style: { - height: '100%', - width: 'inherit', - left: '50%', - transform: 'translateX(-50%)', - }, - }, - { - props: { track: 'inverted' }, - style: { - opacity: 1, - }, - }, - ], -}); +})({}); export const SliderTrack = styled('span', { name: 'MuiSlider', slot: 'Track', -})( - memoTheme(({ theme }) => { - return { - display: 'block', - position: 'absolute', - borderRadius: 'inherit', - border: '1px solid currentColor', - backgroundColor: 'currentColor', - ...getTransitionStyles(theme, ['left', 'width', 'bottom', 'height'], { - duration: theme.transitions.duration.shortest, - }), - variants: [ - { - props: { size: 'small' }, - style: { - '@media (forced-colors: none)': { - border: 'none', - }, - }, - }, - { - props: { orientation: 'horizontal' }, - style: { - height: 'inherit', - top: '50%', - transform: 'translateY(-50%)', - }, - }, - { - props: { orientation: 'vertical' }, - style: { - width: 'inherit', - left: '50%', - transform: 'translateX(-50%)', - }, - }, - { - props: { track: false }, - style: { - display: 'none', - }, - }, - ...Object.entries(theme.palette) - .filter(createSimplePaletteValueFilter()) - .map(([color]) => ({ - props: { color, track: 'inverted' }, - style: { - ...(theme.vars - ? { - backgroundColor: theme.vars.palette.Slider[`${color}Track`], - borderColor: theme.vars.palette.Slider[`${color}Track`], - } - : { - backgroundColor: theme.lighten(theme.palette[color].main, 0.62), - borderColor: theme.lighten(theme.palette[color].main, 0.62), - ...theme.applyStyles('dark', { - backgroundColor: theme.darken(theme.palette[color].main, 0.5), - }), - ...theme.applyStyles('dark', { - borderColor: theme.darken(theme.palette[color].main, 0.5), - }), - }), - }, - })), - ], - }; - }), -); +})({}); export const SliderThumb = styled('span', { name: 'MuiSlider', slot: 'Thumb', -})( - memoTheme(({ theme }) => ({ - position: 'absolute', - width: 20, - height: 20, - boxSizing: 'border-box', - borderRadius: '50%', - outline: 0, - backgroundColor: 'currentColor', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - ...getTransitionStyles(theme, ['box-shadow', 'left', 'bottom'], { - duration: theme.transitions.duration.shortest, - }), - '@media (forced-colors: active)': { - border: '1px solid ButtonBorder', - }, - '&::before': { - position: 'absolute', - content: '""', - borderRadius: 'inherit', - width: '100%', - height: '100%', - boxShadow: (theme.vars || theme).shadows[2], - }, - '&::after': { - position: 'absolute', - content: '""', - borderRadius: '50%', - // 42px is the hit target - width: 42, - height: 42, - top: '50%', - left: '50%', - transform: 'translate(-50%, -50%)', - }, - [`&.${sliderClasses.disabled}`]: { - '&:hover': { - boxShadow: 'none', - }, - }, - variants: [ - { - props: { size: 'small' }, - style: { - width: 12, - height: 12, - '&::before': { - boxShadow: 'none', - }, - }, - }, - { - props: { orientation: 'horizontal' }, - style: { - top: '50%', - transform: 'translate(-50%, -50%)', - }, - }, - { - props: { orientation: 'vertical' }, - style: { - left: '50%', - transform: 'translate(-50%, 50%)', - }, - }, - ...Object.entries(theme.palette) - .filter(createSimplePaletteValueFilter()) - .map(([color]) => ({ - props: { color }, - style: { - [`&:hover, &.${sliderClasses.focusVisible}`]: { - boxShadow: `0px 0px 0px 8px ${theme.alpha((theme.vars || theme).palette[color].main, 0.16)}`, - '@media (hover: none)': { - boxShadow: 'none', - }, - }, - [`&.${sliderClasses.active}`]: { - boxShadow: `0px 0px 0px 14px ${theme.alpha((theme.vars || theme).palette[color].main, 0.16)}`, - }, - }, - })), - ], - })), -); + overridesResolver: (props, styles) => { + const { ownerState } = props; + return [ + styles.thumb, + styles[`thumbColor${capitalize(ownerState.color)}`], + ownerState.size !== 'medium' && styles[`thumbSize${capitalize(ownerState.size)}`], + ]; + }, +})({}); const SliderValueLabel = styled(BaseSliderValueLabel, { name: 'MuiSlider', slot: 'ValueLabel', -})( - memoTheme(({ theme }) => ({ - zIndex: 1, - whiteSpace: 'nowrap', - ...theme.typography.body2, - fontWeight: 500, - ...getTransitionStyles(theme, ['transform'], { - duration: theme.transitions.duration.shortest, - }), - position: 'absolute', - backgroundColor: (theme.vars || theme).palette.grey[600], - borderRadius: 2, - color: (theme.vars || theme).palette.common.white, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - padding: '0.25rem 0.75rem', - variants: [ - { - props: { orientation: 'horizontal' }, - style: { - transform: 'translateY(-100%) scale(0)', - top: '-10px', - transformOrigin: 'bottom center', - '&::before': { - position: 'absolute', - content: '""', - width: 8, - height: 8, - transform: 'translate(-50%, 50%) rotate(45deg)', - backgroundColor: 'inherit', - bottom: 0, - left: '50%', - }, - [`&.${sliderClasses.valueLabelOpen}`]: { - transform: 'translateY(-100%) scale(1)', - }, - }, - }, - { - props: { orientation: 'vertical' }, - style: { - transform: 'translateY(-50%) scale(0)', - right: '30px', - top: '50%', - transformOrigin: 'right center', - '&::before': { - position: 'absolute', - content: '""', - width: 8, - height: 8, - transform: 'translate(-50%, -50%) rotate(45deg)', - backgroundColor: 'inherit', - right: -8, - top: '50%', - }, - [`&.${sliderClasses.valueLabelOpen}`]: { - transform: 'translateY(-50%) scale(1)', - }, - }, - }, - { - props: { size: 'small' }, - style: { - fontSize: theme.typography.pxToRem(12), - padding: '0.25rem 0.5rem', - }, - }, - { - props: { orientation: 'vertical', size: 'small' }, - style: { - right: '20px', - }, - }, - ], - })), -); +})({}); SliderValueLabel.propTypes /* remove-proptypes */ = { // ┌────────────────────────────── Warning ──────────────────────────────┐ @@ -441,79 +100,13 @@ export const SliderMark = styled('span', { return [styles.mark, markActive && styles.markActive]; }, -})( - memoTheme(({ theme }) => ({ - position: 'absolute', - width: 2, - height: 2, - borderRadius: 1, - backgroundColor: 'currentColor', - variants: [ - { - props: { orientation: 'horizontal' }, - style: { - top: '50%', - transform: 'translate(-1px, -50%)', - }, - }, - { - props: { orientation: 'vertical' }, - style: { - left: '50%', - transform: 'translate(-50%, 1px)', - }, - }, - { - props: { markActive: true }, - style: { - backgroundColor: (theme.vars || theme).palette.background.paper, - opacity: 0.8, - }, - }, - ], - })), -); +})({}); export const SliderMarkLabel = styled('span', { name: 'MuiSlider', slot: 'MarkLabel', shouldForwardProp: (prop) => slotShouldForwardProp(prop) && prop !== 'markLabelActive', -})( - memoTheme(({ theme }) => ({ - ...theme.typography.body2, - color: (theme.vars || theme).palette.text.secondary, - position: 'absolute', - whiteSpace: 'nowrap', - variants: [ - { - props: { orientation: 'horizontal' }, - style: { - top: 30, - transform: 'translateX(-50%)', - '@media (pointer: coarse)': { - top: 40, - }, - }, - }, - { - props: { orientation: 'vertical' }, - style: { - left: 36, - transform: 'translateY(50%)', - '@media (pointer: coarse)': { - left: 44, - }, - }, - }, - { - props: { markLabelActive: true }, - style: { - color: (theme.vars || theme).palette.text.primary, - }, - }, - ], - })), -); +})({}); const useUtilityClasses = (ownerState) => { const { disabled, dragging, marked, orientation, track, classes, color, size } = ownerState; @@ -524,26 +117,55 @@ const useUtilityClasses = (ownerState) => { disabled && 'disabled', dragging && 'dragging', marked && 'marked', + orientation === 'horizontal' && 'horizontal', orientation === 'vertical' && 'vertical', track === 'inverted' && 'trackInverted', track === false && 'trackFalse', color && `color${capitalize(color)}`, size && `size${capitalize(size)}`, ], - rail: ['rail'], - track: ['track'], + rail: [ + 'rail', + orientation === 'horizontal' && 'horizontal', + track === 'inverted' && 'trackInverted', + orientation === 'vertical' && 'vertical', + ], + track: [ + 'track', + orientation === 'horizontal' && 'horizontal', + track === 'inverted' && 'trackInverted', + orientation === 'vertical' && 'vertical', + color && `color${capitalize(color)}`, + size && `size${capitalize(size)}`, + ], mark: ['mark'], markActive: ['markActive'], - markLabel: ['markLabel'], + markLabel: [ + 'markLabel', + orientation === 'horizontal' && 'horizontal', + orientation === 'vertical' && 'vertical', + ], markLabelActive: ['markLabelActive'], - valueLabel: ['valueLabel'], - thumb: ['thumb', disabled && 'disabled'], + valueLabel: [ + 'valueLabel', + size && `size${capitalize(size)}`, + orientation === 'horizontal' && 'horizontal', + orientation === 'vertical' && 'vertical', + ], + thumb: [ + 'thumb', + disabled && 'disabled', + size && `thumbSize${capitalize(size)}`, + color && `thumbColor${capitalize(color)}`, + orientation === 'horizontal' && 'horizontal', + orientation === 'vertical' && 'vertical', + ], active: ['active'], disabled: ['disabled'], focusVisible: ['focusVisible'], }; - return composeClasses(slots, getSliderUtilityClass, classes); + return composeClasses(slots, getSliderUtilityClass, classes, cssModules); }; const Forward = ({ children }) => children; diff --git a/packages/mui-material/src/Slider/Slider.module.css b/packages/mui-material/src/Slider/Slider.module.css new file mode 100644 index 00000000000000..8eaadb4c859f5e --- /dev/null +++ b/packages/mui-material/src/Slider/Slider.module.css @@ -0,0 +1,462 @@ +@layer mui.default { + .MuiSlider-root { + border-radius: 12px; + box-sizing: content-box; + display: inline-block; + position: relative; + cursor: pointer; + touch-action: none; + -webkit-tap-highlight-color: transparent; + } + + @media print { + .MuiSlider-root { + print-color-adjust: exact; + -webkit-print-color-adjust: exact; + } + } + + .MuiSlider-root.MuiSlider-trackFalse .MuiSlider-track { + display: none; + } + + .MuiSlider-root.MuiSlider-dragging .MuiSlider-thumb, + .MuiSlider-root.MuiSlider-dragging .MuiSlider-track { + transition: none; + } + + .MuiSlider-root.MuiSlider-colorPrimary { + color: var(--mui-palette-primary-main); + } + .MuiSlider-root.MuiSlider-colorSecondary { + color: var(--mui-palette-secondary-main); + } + .MuiSlider-root.MuiSlider-colorWarning { + color: var(--mui-palette-warning-main); + } + .MuiSlider-root.MuiSlider-colorError { + color: var(--mui-palette-error-main); + } + .MuiSlider-root.MuiSlider-colorSuccess { + color: var(--mui-palette-success-main); + } + .MuiSlider-root.MuiSlider-colorInfo { + color: var(--mui-palette-info-main); + } + + .MuiSlider-root.MuiSlider-horizontal { + height: 4px; + width: 100%; + padding-block: 13px; + } + + @media (pointer: coarse) { + .MuiSlider-root.MuiSlider-horizontal { + padding-block: 20px; + } + } + + .MuiSlider-root.MuiSlider-horizontal.MuiSlider-sizeSmall { + height: 2px; + } + + .MuiSlider-root.MuiSlider-horizontal.MuiSlider-marked { + margin-block-end: 20px; + } + + .MuiSlider-root.MuiSlider-vertical { + height: 100%; + width: 4px; + padding-inline: 13px; + } + + @media (pointer: coarse) { + .MuiSlider-root.MuiSlider-vertical { + padding-inline: 20px; + } + } + + .MuiSlider-root.MuiSlider-vertical.MuiSlider-sizeSmall { + width: 2px; + } + + .MuiSlider-root.MuiSlider-vertical.MuiSlider-marked { + margin-inline-end: 44px; + } + + .MuiSlider-root.Mui-disabled { + pointer-events: none; + cursor: default; + color: var(--mui-palette-grey-400); + } + + .MuiSlider-rail { + display: block; + position: absolute; + border-radius: inherit; + background-color: currentColor; + opacity: 0.38; + } + + @media (forced-colors: active) { + .MuiSlider-rail { + border: 1px solid transparent; + box-sizing: border-box; + } + } + + .MuiSlider-rail.MuiSlider-horizontal { + width: 100%; + height: inherit; + inset-block-start: 50%; + transform: translateY(-50%); + } + + .MuiSlider-rail.MuiSlider-vertical { + height: 100%; + width: inherit; + inset-inline-start: 50%; + transform: translateX(-50%); + } + + .MuiSlider-rail.MuiSlider-trackInverted { + opacity: 1; + } + + .MuiSlider-track { + display: block; + position: absolute; + border-radius: inherit; + border: 1px solid currentColor; + background-color: currentColor; + transition: + left var(--mui-transitions-duration-shortest) var(--mui-transitions-easing-easeInOut), + width var(--mui-transitions-duration-shortest) var(--mui-transitions-easing-easeInOut), + bottom var(--mui-transitions-duration-shortest) var(--mui-transitions-easing-easeInOut), + height var(--mui-transitions-duration-shortest) var(--mui-transitions-easing-easeInOut); + } + + .MuiSlider-track.MuiSlider-trackInverted.MuiSlider-colorPrimary { + background-color: var(--mui-palette-Slider-primaryTrack); + border-color: var(--mui-palette-Slider-primaryTrack); + } + + .MuiSlider-track.MuiSlider-trackInverted.MuiSlider-colorSecondary { + background-color: var(--mui-palette-Slider-secondaryTrack); + border-color: var(--mui-palette-Slider-secondaryTrack); + } + + .MuiSlider-track.MuiSlider-trackInverted.MuiSlider-colorError { + background-color: var(--mui-palette-Slider-errorTrack); + border-color: var(--mui-palette-Slider-errorTrack); + } + + .MuiSlider-track.MuiSlider-trackInverted.MuiSlider-colorInfo { + background-color: var(--mui-palette-Slider-infoTrack); + border-color: var(--mui-palette-Slider-infoTrack); + } + + .MuiSlider-track.MuiSlider-trackInverted.MuiSlider-colorSuccess { + background-color: var(--mui-palette-Slider-successTrack); + border-color: var(--mui-palette-Slider-successTrack); + } + + .MuiSlider-track.MuiSlider-trackInverted.MuiSlider-colorWarning { + background-color: var(--mui-palette-Slider-warningTrack); + border-color: var(--mui-palette-Slider-warningTrack); + } + + @media (forced-colors: none) { + .MuiSlider-track.MuiSlider-sizeSmall { + border: none; + } + } + + .MuiSlider-track.MuiSlider-horizontal { + height: inherit; + inset-block-start: 50%; + transform: translateY(-50%); + } + + .MuiSlider-track.MuiSlider-vertical { + width: inherit; + inset-inline-start: 50%; + transform: translateX(-50%); + } + + .MuiSlider-track.MuiSlider-trackFalse { + display: none; + } + + .MuiSlider-thumb { + position: absolute; + width: 20px; + height: 20px; + box-sizing: border-box; + border-radius: 50%; + outline: 0; + background-color: currentColor; + display: flex; + align-items: center; + justify-content: center; + transition: + box-shadow var(--mui-transitions-duration-shortest) var(--mui-transitions-easing-easeInOut), + left var(--mui-transitions-duration-shortest) var(--mui-transitions-easing-easeInOut), + bottom var(--mui-transitions-duration-shortest) var(--mui-transitions-easing-easeInOut); + } + + @media (forced-colors: active) { + .MuiSlider-thumb { + border: 1px solid ButtonBorder; + } + } + + .MuiSlider-thumb::before { + position: absolute; + content: ''; + border-radius: inherit; + width: 100%; + height: 100%; + box-shadow: var(--mui-shadows-2); + } + + .MuiSlider-thumb::after { + position: absolute; + content: ''; + border-radius: 50%; + width: 42px; + height: 42px; + inset-block-start: 50%; + inset-inline-start: 50%; + transform: translate(-50%, -50%); + } + + .MuiSlider-thumb.MuiSlider-thumbColorPrimary:hover, + .MuiSlider-thumb.MuiSlider-thumbColorPrimary.Mui-focusVisible { + box-shadow: 0px 0px 0px 8px rgba(var(--mui-palette-primary-mainChannel) / 0.16); + } + + .MuiSlider-thumb.MuiSlider-thumbColorPrimary.Mui-active { + box-shadow: 0px 0px 0px 14px rgba(var(--mui-palette-primary-mainChannel) / 0.16); + } + + .MuiSlider-thumb.MuiSlider-thumbColorSecondary:hover, + .MuiSlider-thumb.MuiSlider-thumbColorSecondary.Mui-focusVisible { + box-shadow: 0px 0px 0px 8px rgba(var(--mui-palette-secondary-mainChannel) / 0.16); + } + + .MuiSlider-thumb.MuiSlider-thumbColorSecondary.Mui-active { + box-shadow: 0px 0px 0px 14px rgba(var(--mui-palette-secondary-mainChannel) / 0.16); + } + + .MuiSlider-thumb.MuiSlider-thumbColorError:hover, + .MuiSlider-thumb.MuiSlider-thumbColorError.Mui-focusVisible { + box-shadow: 0px 0px 0px 8px rgba(var(--mui-palette-error-mainChannel) / 0.16); + } + + .MuiSlider-thumb.MuiSlider-thumbColorError.Mui-active { + box-shadow: 0px 0px 0px 14px rgba(var(--mui-palette-error-mainChannel) / 0.16); + } + + .MuiSlider-thumb.MuiSlider-thumbColorInfo:hover, + .MuiSlider-thumb.MuiSlider-thumbColorInfo.Mui-focusVisible { + box-shadow: 0px 0px 0px 8px rgba(var(--mui-palette-info-mainChannel) / 0.16); + } + + .MuiSlider-thumb.MuiSlider-thumbColorInfo.Mui-active { + box-shadow: 0px 0px 0px 14px rgba(var(--mui-palette-info-mainChannel) / 0.16); + } + + .MuiSlider-thumb.MuiSlider-thumbColorSuccess:hover, + .MuiSlider-thumb.MuiSlider-thumbColorSuccess.Mui-focusVisible { + box-shadow: 0px 0px 0px 8px rgba(var(--mui-palette-success-mainChannel) / 0.16); + } + + .MuiSlider-thumb.MuiSlider-thumbColorSuccess.Mui-active { + box-shadow: 0px 0px 0px 14px rgba(var(--mui-palette-success-mainChannel) / 0.16); + } + + .MuiSlider-thumb.MuiSlider-thumbColorWarning:hover, + .MuiSlider-thumb.MuiSlider-thumbColorWarning.Mui-focusVisible { + box-shadow: 0px 0px 0px 8px rgba(var(--mui-palette-warning-mainChannel) / 0.16); + } + + .MuiSlider-thumb.MuiSlider-thumbColorWarning.Mui-active { + box-shadow: 0px 0px 0px 14px rgba(var(--mui-palette-warning-mainChannel) / 0.16); + } + + @media (hover: none) { + .MuiSlider-thumb.MuiSlider-thumbColorPrimary:hover, + .MuiSlider-thumb.MuiSlider-thumbColorSecondary:hover, + .MuiSlider-thumb.MuiSlider-thumbColorError:hover, + .MuiSlider-thumb.MuiSlider-thumbColorInfo:hover, + .MuiSlider-thumb.MuiSlider-thumbColorSuccess:hover, + .MuiSlider-thumb.MuiSlider-thumbColorWarning:hover { + box-shadow: none; + } + } + + .MuiSlider-thumb.MuiSlider-thumbSizeSmall { + width: 12px; + height: 12px; + } + + .MuiSlider-thumb.MuiSlider-thumbSizeSmall::before { + box-shadow: none; + } + + .MuiSlider-thumb.MuiSlider-horizontal { + inset-block-start: 50%; + transform: translate(-50%, -50%); + } + + [dir='rtl'] .MuiSlider-thumb.MuiSlider-horizontal { + transform: translate(50%, -50%); + } + + .MuiSlider-thumb.MuiSlider-vertical { + inset-inline-start: 50%; + transform: translate(-50%, 50%); + } + + .MuiSlider-thumb.Mui-disabled:hover { + box-shadow: none; + } + + .MuiSlider-valueLabel { + z-index: 1; + white-space: nowrap; + font-size: 0.875rem; + line-height: 1.43; + letter-spacing: 0.01071em; + font-weight: 500; + transition: transform var(--mui-transitions-duration-shortest) + var(--mui-transitions-easing-easeInOut); + position: absolute; + background-color: var(--mui-palette-grey-600); + border-radius: 2px; + color: var(--mui-palette-common-white); + display: flex; + align-items: center; + justify-content: center; + padding: 0.25rem 0.75rem; + } + + .MuiSlider-valueLabel.MuiSlider-horizontal { + transform: translateY(-100%) scale(0); + inset-block-start: -10px; + transform-origin: block-end center; + } + + .MuiSlider-valueLabel.MuiSlider-horizontal::before { + position: absolute; + content: ''; + width: 8px; + height: 8px; + transform: translate(-50%, 50%) rotate(45deg); + background-color: inherit; + inset-block-end: 0; + inset-inline-start: 50%; + } + + .MuiSlider-valueLabel.MuiSlider-horizontal.MuiSlider-valueLabelOpen { + transform: translateY(-100%) scale(1); + } + + .MuiSlider-valueLabel.MuiSlider-vertical { + transform: translateY(-50%) scale(0); + inset-inline-end: 30px; + inset-block-start: 50%; + transform-origin: inline-end center; + } + + .MuiSlider-valueLabel.MuiSlider-vertical::before { + position: absolute; + content: ''; + width: 8px; + height: 8px; + transform: translate(-50%, -50%) rotate(45deg); + background-color: inherit; + inset-inline-end: -8px; + inset-block-start: 50%; + } + + .MuiSlider-valueLabel.MuiSlider-vertical.MuiSlider-valueLabelOpen { + transform: translateY(-50%) scale(1); + } + + .MuiSlider-valueLabel.MuiSlider-sizeSmall { + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + } + + .MuiSlider-valueLabel.MuiSlider-vertical.MuiSlider-sizeSmall { + inset-inline-end: 20px; + } + + .MuiSlider-mark { + position: absolute; + width: 2px; + height: 2px; + border-radius: 1px; + background-color: currentColor; + } + + .MuiSlider-mark.MuiSlider-horizontal { + inset-block-start: 50%; + transform: translate(-1px, -50%); + } + + [dir='rtl'] .MuiSlider-mark.MuiSlider-horizontal { + transform: translate(1px, -50%); + } + + .MuiSlider-mark.MuiSlider-vertical { + inset-inline-start: 50%; + transform: translate(-50%, 1px); + } + + .MuiSlider-mark.MuiSlider-markActive { + background-color: var(--mui-palette-background-paper); + opacity: 0.8; + } + + .MuiSlider-markLabel { + font-size: 0.875rem; + line-height: 1.43; + letter-spacing: 0.01071em; + color: var(--mui-palette-text-secondary); + position: absolute; + white-space: nowrap; + } + + .MuiSlider-markLabel.MuiSlider-horizontal { + inset-block-start: 30px; + transform: translateX(-50%); + } + + [dir='rtl'] .MuiSlider-markLabel.MuiSlider-horizontal { + transform: translateX(50%); + } + + @media (pointer: coarse) { + .MuiSlider-markLabel.MuiSlider-horizontal { + inset-block-start: 40px; + } + } + + .MuiSlider-markLabel.MuiSlider-vertical { + inset-inline-start: 36px; + transform: translateY(50%); + } + + @media (pointer: coarse) { + .MuiSlider-markLabel.MuiSlider-vertical { + inset-inline-start: 44px; + } + } + + .MuiSlider-markLabel.MuiSlider-markLabelActive { + color: var(--mui-palette-text-primary); + } +} diff --git a/packages/mui-material/src/styles/CssThemeProvider.test.tsx b/packages/mui-material/src/styles/CssThemeProvider.test.tsx new file mode 100644 index 00000000000000..5638e1d302fe07 --- /dev/null +++ b/packages/mui-material/src/styles/CssThemeProvider.test.tsx @@ -0,0 +1,74 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { createRenderer } from '@mui/internal-test-utils'; +import { createTheme } from '@mui/material/styles'; +import CssThemeProvider from './CssThemeProvider'; +import useTheme from './useTheme'; + +describe('CssThemeProvider', () => { + const { render } = createRenderer(); + + afterEach(() => { + document.querySelectorAll('[id$="-css-vars"]').forEach((el) => el.remove()); + }); + + it('throws when the theme was not created with cssVariables: true', () => { + const theme = createTheme(); + expect(() => render({null})).to.throw( + 'MUI: CssThemeProvider requires a theme created with `cssVariables: true`', + ); + }); + + it('throws when passed a theme function', () => { + const themeFn = () => createTheme({ cssVariables: true }); + expect(() => render({null})).to.throw( + 'MUI: CssThemeProvider does not accept a theme function', + ); + }); + + it('injects CSS variables into ', () => { + const theme = createTheme({ cssVariables: true }); + render( + +
+ , + ); + const style = document.getElementById('mui-css-vars'); + expect(style).not.to.equal(null); + expect(style!.textContent).to.include('--mui-palette-primary-main'); + }); + + it('makes the theme available via useTheme()', () => { + const theme = createTheme({ + cssVariables: true, + palette: { primary: { main: '#ff0000' } }, + }); + let observed: any; + function Probe() { + observed = useTheme(); + return null; + } + render( + + + , + ); + // Theme is wrapped in CSS-vars mode — palette values are var() strings, + // but transitions/breakpoints/spacing/etc. remain plain JS values. + expect(observed).not.to.equal(undefined); + expect(observed.breakpoints).to.be.an('object'); + expect(observed.spacing(2)).to.equal('16px'); + expect(observed.transitions.duration.shortest).to.equal(150); + }); + + it('uses the cssVarPrefix to namespace the injected +
+ ); +} + +export default function App() { + return ( + + + + ); +} diff --git a/test/css-theme-provider-vite-sandbox/src/main.tsx b/test/css-theme-provider-vite-sandbox/src/main.tsx new file mode 100644 index 00000000000000..033af594370fdc --- /dev/null +++ b/test/css-theme-provider-vite-sandbox/src/main.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import * as ReactDOMClient from 'react-dom/client'; +import App from './App'; + +const container = document.getElementById('root'); +if (!container) { + throw new Error('Missing #root element'); +} + +ReactDOMClient.createRoot(container).render( + + + , +); diff --git a/test/css-theme-provider-vite-sandbox/src/vite-env.d.ts b/test/css-theme-provider-vite-sandbox/src/vite-env.d.ts new file mode 100644 index 00000000000000..11f02fe2a0061d --- /dev/null +++ b/test/css-theme-provider-vite-sandbox/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/test/css-theme-provider-vite-sandbox/tsconfig.json b/test/css-theme-provider-vite-sandbox/tsconfig.json new file mode 100644 index 00000000000000..7b1f33c4f6a88a --- /dev/null +++ b/test/css-theme-provider-vite-sandbox/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/test/css-theme-provider-vite-sandbox/vite.config.ts b/test/css-theme-provider-vite-sandbox/vite.config.ts new file mode 100644 index 00000000000000..adf97006a054e9 --- /dev/null +++ b/test/css-theme-provider-vite-sandbox/vite.config.ts @@ -0,0 +1,73 @@ +// import path from 'node:path'; +// import { fileURLToPath } from 'node:url'; +import { defineConfig, transformWithEsbuild } from 'vite'; +import react from '@vitejs/plugin-react'; + +// const dirname = path.dirname(fileURLToPath(import.meta.url)); +// Two levels up: test/css-theme-provider-vite-sandbox → test → monorepo root +// const MONOREPO_ROOT = path.resolve(dirname, '../..'); + +// https://vite.dev/config/ +// Use the function form so `mode` is available for the NODE_ENV define. +export default defineConfig(({ mode }) => ({ + // MUI source files reference process.env.NODE_ENV for dev-only warnings. + // Vite doesn't polyfill `process` in browser builds, so we define it explicitly. + define: { + 'process.env.NODE_ENV': JSON.stringify(mode === 'production' ? 'production' : 'development'), + }, + optimizeDeps: { + esbuildOptions: { + // MUI source files use .js extension but contain JSX syntax. + // Tell esbuild's pre-bundler to treat them as JSX. + loader: { '.js': 'jsx' }, + }, + }, + plugins: [ + // @mui/material source files use .js extension but contain JSX. + // This plugin re-parses them as JSX so Rollup/esbuild can handle them. + // enforce: 'pre' ensures this plugin runs before Vite's internal esbuild + // pre-transform, which would otherwise reject JSX in .js files. + { + name: 'treat-js-files-as-jsx', + enforce: 'pre' as const, + transform(code, id) { + if (/\/node_modules\//.test(id)) { + return null; + } + if (id.startsWith('\0')) { + return null; + } + if (!/.*\.js$/.test(id)) { + return null; + } + return transformWithEsbuild(code, id, { loader: 'jsx' }); + }, + }, + react(), + ], + resolve: { + alias: [ + // These aliases used to point @mui/* packages at their monorepo `src/` + // directories so Vite would pick up in-flight source changes without a + // rebuild. Now that the packages are built (pnpm -F @mui/material build + // etc.), the workspace symlinks + each package's `exports` field in + // build/package.json are enough — no alias needed. + // + // If you want to test against source again (e.g. mid-refactor before + // rebuilding), un-comment these and re-add the `treat-js-files-as-jsx` + // plugin (source .js files contain JSX; built .mjs files do not). + // + // { find: '@mui/material', replacement: path.resolve(MONOREPO_ROOT, 'packages/mui-material/src') }, + // { find: '@mui/system', replacement: path.resolve(MONOREPO_ROOT, 'packages/mui-system/src') }, + // { find: '@mui/utils', replacement: path.resolve(MONOREPO_ROOT, 'packages/mui-utils/src') }, + // { find: '@mui/private-theming', replacement: path.resolve(MONOREPO_ROOT, 'packages/mui-private-theming/src') }, + + // THE KEY ALIAS: swap Emotion for the zero-runtime noop engine. + // This is exactly what non-Emotion users configure in their own bundler. + { + find: '@mui/styled-engine', + replacement: '@mui/styled-engine-noop', + }, + ], + }, +})); diff --git a/test/emotion-vite-sandbox/README.md b/test/emotion-vite-sandbox/README.md new file mode 100644 index 00000000000000..b870c1c17ea8f6 --- /dev/null +++ b/test/emotion-vite-sandbox/README.md @@ -0,0 +1,74 @@ +# emotion-vite-sandbox + +A minimal Vite + React app that exercises the **Emotion (default) path** for +Material UI components. + +## Purpose + +Verifies that: + +1. `@mui/styled-engine` resolves to the real Emotion-backed engine (bundle + contains `@emotion/*`). +2. `Slider` renders correctly via `ThemeProvider` + Emotion-generated styles. +3. The `sx` prop works and applies styles at runtime via Emotion. +4. `className`-based overrides work alongside Emotion-generated class names. +5. Dark mode works via `ThemeProvider`'s `colorSchemes` / `CssVarsProvider` + by flipping `data-mui-color-scheme="dark"` on `document.documentElement`. + +### Why only Slider? + +Only `Slider` is used because it is the first component converted to the +CSS-base pattern. It serves as the comparison baseline against +`test/noop-vite-sandbox` — same component, same visual output, different +engine. Bundle size delta between the two sandboxes measures the Emotion +overhead. + +## Dev server + +```bash +pnpm -F @mui-internal/emotion-vite-sandbox dev +``` + +Then open . + +## Production build + +```bash +pnpm -F @mui-internal/emotion-vite-sandbox build +``` + +## Verifying Emotion is bundled + +After a production build, confirm `@emotion/*` runtime is present: + +```bash +# Should print matches +grep -r "@emotion" test/emotion-vite-sandbox/dist/ +``` + +## Bundle size comparison + +Build all three sandboxes, then compare JavaScript output sizes to measure the overhead +of each approach: + +```bash +pnpm -F @mui-internal/noop-vite-sandbox build +pnpm -F @mui-internal/css-theme-provider-vite-sandbox build +pnpm -F @mui-internal/emotion-vite-sandbox build + +# Compare JS sizes +ls -lh test/noop-vite-sandbox/dist/assets/*.js +ls -lh test/css-theme-provider-vite-sandbox/dist/assets/*.js +ls -lh test/emotion-vite-sandbox/dist/assets/*.js + +# Compare CSS sizes +ls -lh test/noop-vite-sandbox/dist/assets/*.css +ls -lh test/css-theme-provider-vite-sandbox/dist/assets/*.css +ls -lh test/emotion-vite-sandbox/dist/assets/*.css +``` + +## Relation to the TODO + +This sandbox maps to **§8 (Bundle size validation)** in `STYLING_V8_TODO.md`. +It is the Emotion-path counterpart to `test/noop-vite-sandbox`, used to +measure the JavaScript bundle delta between the two engines. diff --git a/test/emotion-vite-sandbox/index.html b/test/emotion-vite-sandbox/index.html new file mode 100644 index 00000000000000..c6217cd200816f --- /dev/null +++ b/test/emotion-vite-sandbox/index.html @@ -0,0 +1,12 @@ + + + + + + MUI emotion Vite sandbox + + +
+ + + diff --git a/test/emotion-vite-sandbox/package.json b/test/emotion-vite-sandbox/package.json new file mode 100644 index 00000000000000..93e2b152f820c0 --- /dev/null +++ b/test/emotion-vite-sandbox/package.json @@ -0,0 +1,28 @@ +{ + "name": "@mui-internal/emotion-vite-sandbox", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "@mui/material": "workspace:*", + "@mui/system": "workspace:*", + "@mui/utils": "workspace:*", + "@mui/styled-engine": "workspace:*", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^5.1.1", + "typescript": "^6.0.3", + "vite": "^7.3.1" + } +} diff --git a/test/emotion-vite-sandbox/src/App.tsx b/test/emotion-vite-sandbox/src/App.tsx new file mode 100644 index 00000000000000..dd7a1478303e80 --- /dev/null +++ b/test/emotion-vite-sandbox/src/App.tsx @@ -0,0 +1,99 @@ +/** + * emotion-engine sandbox — Slider only. + * + * Mirror of noop-vite-sandbox but using the Emotion engine (the default MUI + * setup). No engine alias is applied — @mui/styled-engine resolves to the + * real Emotion-backed package. + * + * Verifies that: + * 1. @mui/styled-engine resolves to the Emotion engine (bundle contains @emotion/*). + * 2. Slider renders correctly via ThemeProvider + Emotion-generated styles. + * 3. The `sx` prop works and applies styles at runtime via Emotion. + * 4. Dark mode works via ThemeProvider's colorSchemes / CssVarsProvider. + * 5. useTheme() returns live JS theme values. + * + * To confirm Emotion IS bundled, run: + * pnpm -F @mui-internal/emotion-vite-sandbox build + * Then: + * grep -r "@emotion" dist/ + * # should print matches + */ +import * as React from 'react'; +import { createTheme, ThemeProvider, useColorScheme } from '@mui/material/styles'; +import Slider from '@mui/material/Slider'; + +const theme = createTheme({ + cssVariables: { + colorSchemeSelector: '[data-mui-color-scheme="%s"]', + }, + colorSchemes: { light: true, dark: true }, +}); + +function AppContent() { + const [value, setValue] = React.useState(40); + const { mode, setMode } = useColorScheme(); + + return ( +
+

emotion-engine sandbox

+

+ Engine: @mui/styled-engine (Emotion) — Emotion is bundled here. +

+ + + +
+

Slider value: {value}

+ + {/* sx prop — works with Emotion, applies color override at runtime */} + setValue(v as number)} + sx={{ color: 'secondary.main' }} + aria-label="Sandbox slider" + /> + +

+ className override (thumb should be purple, overriding the default via plain CSS): +

+ + setValue(v as number)} + className="custom-slider" + aria-label="Custom class slider" + /> +
+ + +
+ ); +} + +export default function App() { + return ( + + + + ); +} diff --git a/test/emotion-vite-sandbox/src/main.tsx b/test/emotion-vite-sandbox/src/main.tsx new file mode 100644 index 00000000000000..033af594370fdc --- /dev/null +++ b/test/emotion-vite-sandbox/src/main.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import * as ReactDOMClient from 'react-dom/client'; +import App from './App'; + +const container = document.getElementById('root'); +if (!container) { + throw new Error('Missing #root element'); +} + +ReactDOMClient.createRoot(container).render( + + + , +); diff --git a/test/emotion-vite-sandbox/src/vite-env.d.ts b/test/emotion-vite-sandbox/src/vite-env.d.ts new file mode 100644 index 00000000000000..11f02fe2a0061d --- /dev/null +++ b/test/emotion-vite-sandbox/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/test/emotion-vite-sandbox/tsconfig.json b/test/emotion-vite-sandbox/tsconfig.json new file mode 100644 index 00000000000000..7b1f33c4f6a88a --- /dev/null +++ b/test/emotion-vite-sandbox/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/test/emotion-vite-sandbox/vite.config.ts b/test/emotion-vite-sandbox/vite.config.ts new file mode 100644 index 00000000000000..2cacd2a92a3af8 --- /dev/null +++ b/test/emotion-vite-sandbox/vite.config.ts @@ -0,0 +1,66 @@ +// import path from 'node:path'; +// import { fileURLToPath } from 'node:url'; +import { defineConfig, transformWithEsbuild } from 'vite'; +import react from '@vitejs/plugin-react'; + +// const dirname = path.dirname(fileURLToPath(import.meta.url)); +// const MONOREPO_ROOT = path.resolve(dirname, '../..'); + +// https://vite.dev/config/ +// Use the function form so `mode` is available for the NODE_ENV define. +export default defineConfig(({ mode }) => ({ + // MUI source files reference process.env.NODE_ENV for dev-only warnings. + // Vite doesn't polyfill `process` in browser builds, so we define it explicitly. + define: { + 'process.env.NODE_ENV': JSON.stringify(mode === 'production' ? 'production' : 'development'), + }, + optimizeDeps: { + esbuildOptions: { + // MUI source files use .js extension but contain JSX syntax. + // Tell esbuild's pre-bundler to treat them as JSX. + loader: { '.js': 'jsx' }, + }, + }, + plugins: [ + // @mui/material source files use .js extension but contain JSX. + // This plugin re-parses them as JSX so Rollup/esbuild can handle them. + // enforce: 'pre' ensures this plugin runs before Vite's internal esbuild + // pre-transform, which would otherwise reject JSX in .js files. + { + name: 'treat-js-files-as-jsx', + enforce: 'pre' as const, + transform(code, id) { + if (/\/node_modules\//.test(id)) { + return null; + } + if (id.startsWith('\0')) { + return null; + } + if (!/.*\.js$/.test(id)) { + return null; + } + return transformWithEsbuild(code, id, { loader: 'jsx' }); + }, + }, + react(), + ], + resolve: { + alias: [ + // These aliases used to point @mui/* packages at their monorepo `src/` + // directories so Vite would pick up in-flight source changes without a + // rebuild. Now that the packages are built (pnpm -F @mui/material build + // etc.), the workspace symlinks + each package's `exports` field in + // build/package.json are enough — no alias needed. + // + // If you want to test against source again (e.g. mid-refactor before + // rebuilding), un-comment these and re-add the `treat-js-files-as-jsx` + // plugin (source .js files contain JSX; built .mjs files do not). + // + // { find: '@mui/material', replacement: path.resolve(MONOREPO_ROOT, 'packages/mui-material/src') }, + // { find: '@mui/system', replacement: path.resolve(MONOREPO_ROOT, 'packages/mui-system/src') }, + // { find: '@mui/utils', replacement: path.resolve(MONOREPO_ROOT, 'packages/mui-utils/src') }, + // { find: '@mui/private-theming', replacement: path.resolve(MONOREPO_ROOT, 'packages/mui-private-theming/src') }, + // { find: '@mui/styled-engine', replacement: path.resolve(MONOREPO_ROOT, 'packages/mui-styled-engine/src') }, + ], + }, +})); diff --git a/tsconfig.json b/tsconfig.json index 11af6fb5ec18bb..dacdd207b475a3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,6 +26,8 @@ "@mui/internal-markdown/prism": ["./packages-internal/markdown/prism.mjs"], "@mui/styled-engine": ["./packages/mui-styled-engine/src"], "@mui/styled-engine/*": ["./packages/mui-styled-engine/src/*"], + "@mui/tailwind": ["./packages/mui-tailwind/src/preset.js"], + "@mui/tailwind/*": ["./packages/mui-tailwind/src/*"], "@mui/styled-engine-sc": ["./packages/mui-styled-engine-sc/src"], "@mui/styled-engine-sc/*": ["./packages/mui-styled-engine-sc/src/*"], "@mui/system": ["./packages/mui-system/src"],