init project
Some checks failed
No response / noResponse (push) Has been cancelled
CI / Continuous releases (push) Has been cancelled
CI / test-dev (macos-latest) (push) Has been cancelled
CI / test-dev (ubuntu-latest) (push) Has been cancelled
CI / test-dev (windows-latest) (push) Has been cancelled
Maintenance / main (push) Has been cancelled
Scorecards supply-chain security / Scorecards analysis (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled

This commit is contained in:
how2ice
2025-12-12 14:26:25 +09:00
commit 005cf56baf
43188 changed files with 1079531 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
# MUI System
MUI System is a set of CSS utilities to help you build custom designs more efficiently. It makes it possible to rapidly lay out custom designs.
## Installation
Install the package in your project directory with:
<!-- #npm-tag-reference -->
```bash
npm install @mui/system @emotion/react @emotion/styled
```
## Documentation
<!-- #host-reference -->
Visit [https://mui.com/system/getting-started/](https://mui.com/system/getting-started/) to view the full documentation.

View File

@@ -0,0 +1,89 @@
{
"name": "@mui/system",
"version": "7.3.6",
"author": "MUI Team",
"description": "MUI System is a set of CSS utilities to help you build custom designs more efficiently. It makes it possible to rapidly lay out custom designs.",
"keywords": [
"react",
"react-component",
"mui",
"system"
],
"repository": {
"type": "git",
"url": "git+https://github.com/mui/material-ui.git",
"directory": "packages/mui-system"
},
"license": "MIT",
"bugs": {
"url": "https://github.com/mui/material-ui/issues"
},
"homepage": "https://mui.com/system/getting-started/",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"scripts": {
"build": "code-infra build",
"release": "pnpm build && pnpm publish",
"test": "pnpm --workspace-root test:unit --project \"*:@mui/system\"",
"typescript": "tsc -p tsconfig.json",
"typescript:module-augmentation": "node scripts/testModuleAugmentation.js",
"attw": "attw --pack ./build --exclude-entrypoints esm modern --include-entrypoints borders"
},
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/private-theming": "workspace:^",
"@mui/styled-engine": "workspace:^",
"@mui/types": "workspace:^",
"@mui/utils": "workspace:^",
"clsx": "^2.1.1",
"csstype": "^3.2.3",
"prop-types": "^15.8.1"
},
"devDependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/internal-test-utils": "workspace:^",
"@mui/system": "workspace:*",
"@types/chai": "^5.2.3",
"@types/prop-types": "^15.7.15",
"@types/react": "^19.2.7",
"@types/sinon": "^17.0.4",
"chai": "^6.0.1",
"es-toolkit": "^1.39.10",
"fast-glob": "^3.3.3",
"react": "^19.2.1",
"sinon": "^21.0.0",
"styled-components": "^6.1.19"
},
"peerDependencies": {
"@emotion/react": "^11.5.0",
"@emotion/styled": "^11.3.0",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@emotion/react": {
"optional": true
},
"@emotion/styled": {
"optional": true
}
},
"sideEffects": false,
"publishConfig": {
"access": "public",
"directory": "build"
},
"engines": {
"node": ">=14.0.0"
},
"exports": {
".": "./src/index.js",
"./*": "./src/*/index.ts"
}
}

View File

@@ -0,0 +1,61 @@
const childProcess = require('child_process');
const path = require('path');
const { promisify } = require('util');
const { chunk } = require('es-toolkit/array');
const glob = require('fast-glob');
const exec = promisify(childProcess.exec);
const packageRoot = path.resolve(__dirname, '../');
async function test(tsconfigPath) {
try {
await exec(['pnpm', 'tsc', '--project', tsconfigPath].join(' '), { cwd: packageRoot });
} catch (error) {
if (error.stdout !== undefined) {
// `exec` error
throw new Error(`exit code ${error.code}: ${error.stdout}`);
}
// Unknown error
throw error;
}
}
/**
* Tests various module augmentation scenarios.
* We can't run them with a single `tsc` run since these apply globally.
* Running them all would mean they're not isolated.
* Each test case represents a section in our docs.
*/
async function main() {
const tsconfigPaths = await glob('test/typescript/moduleAugmentation/*.tsconfig.json', {
absolute: true,
cwd: packageRoot,
});
// Need to process in chunks or we might run out-of-memory
// approximate pnpm lerna --concurrency 7
const tsconfigPathsChunks = chunk(tsconfigPaths, 7);
for await (const tsconfigPathsChunk of tsconfigPathsChunks) {
await Promise.all(
tsconfigPathsChunk.map(async (tsconfigPath) => {
await test(tsconfigPath).then(
() => {
// eslint-disable-next-line no-console -- test runner feedback
console.log(`PASS ${path.relative(process.cwd(), tsconfigPath)}`);
},
(error) => {
// don't bail but log the error
console.error(`FAIL ${path.relative(process.cwd(), tsconfigPath)}\n ${error}`);
// and mark the test as failed
process.exitCode = 1;
},
);
}),
);
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,92 @@
import * as React from 'react';
import { OverridableComponent, OverrideProps } from '@mui/types';
import { Theme as SystemTheme } from '../createTheme';
import {
SxProps,
AllSystemCSSProperties,
ResponsiveStyleValue,
OverwriteCSSProperties,
AliasesCSSProperties,
} from '../styleFunctionSx';
import { PropsFor } from '../style';
import { ComposedStyleFunction } from '../compose';
import borders from '../borders';
import display from '../display';
import flexbox from '../flexbox';
import grid from '../cssGrid';
import palette from '../palette';
import positions from '../positions';
import shadows from '../shadows';
import sizing from '../sizing';
import spacing from '../spacing';
import typography from '../typography';
export interface CustomSystemProps extends AliasesCSSProperties, OverwriteCSSProperties {}
export type SimpleSystemKeys = keyof PropsFor<
ComposedStyleFunction<
[
typeof borders,
typeof display,
typeof flexbox,
typeof grid,
typeof palette,
typeof positions,
typeof shadows,
typeof sizing,
typeof spacing,
typeof typography,
]
>
>;
// The SimpleSystemKeys are subset of the AllSystemCSSProperties, so this should be ok
// This is needed as these are used as keys inside AllSystemCSSProperties
type StandardSystemKeys = Extract<SimpleSystemKeys, keyof AllSystemCSSProperties>;
export type SystemProps<Theme extends object = {}> = {
[K in StandardSystemKeys]?:
| ResponsiveStyleValue<AllSystemCSSProperties[K]>
| ((theme: Theme) => ResponsiveStyleValue<AllSystemCSSProperties[K]>);
};
export interface BoxOwnProps<Theme extends object = SystemTheme> extends SystemProps<Theme> {
children?: React.ReactNode;
ref?: React.Ref<unknown>;
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx?: SxProps<Theme>;
}
export interface BoxTypeMap<
AdditionalProps = {},
RootComponent extends React.ElementType = 'div',
Theme extends object = SystemTheme,
> {
props: AdditionalProps & BoxOwnProps<Theme>;
defaultComponent: RootComponent;
}
/**
*
* Demos:
*
* - [Box (Joy UI)](https://mui.com/joy-ui/react-box/)
* - [Box (Material UI)](https://mui.com/material-ui/react-box/)
* - [Box (MUI System)](https://mui.com/system/react-box/)
*
* API:
*
* - [Box API](https://mui.com/system/api/box/)
*/
declare const Box: OverridableComponent<BoxTypeMap>;
export type BoxProps<
RootComponent extends React.ElementType = BoxTypeMap['defaultComponent'],
AdditionalProps = {},
> = OverrideProps<BoxTypeMap<AdditionalProps, RootComponent>, RootComponent> & {
component?: React.ElementType;
};
export default Box;

View File

@@ -0,0 +1,36 @@
'use client';
import PropTypes from 'prop-types';
import ClassNameGenerator from '@mui/utils/ClassNameGenerator';
import createBox from '../createBox';
import boxClasses from './boxClasses';
const Box = createBox({
defaultClassName: boxClasses.root,
generateClassName: ClassNameGenerator.generate,
});
Box.propTypes /* remove-proptypes */ = {
// ┌────────────────────────────── Warning ──────────────────────────────┐
// │ These PropTypes are generated from the TypeScript type definitions. │
// │ To update them, edit the d.ts file and run `pnpm proptypes`. │
// └─────────────────────────────────────────────────────────────────────┘
/**
* @ignore
*/
children: PropTypes.node,
/**
* The component used for the root node.
* Either a string to use a HTML element or a component.
*/
component: PropTypes.elementType,
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])),
PropTypes.func,
PropTypes.object,
]),
};
export default Box;

View File

@@ -0,0 +1,122 @@
import { Box, styled } from '@mui/system';
interface TestProps {
test?: string;
}
function Test(props: TestProps) {
const { test, ...other } = props;
return <span {...other}>{test}</span>;
}
function ResponsiveTest() {
<Box sx={{ p: [2, 3, 4] }} />;
<Box sx={{ p: { xs: 2, sm: 3, md: 4 } }} />;
<Box sx={{ fontSize: [12, 18, 24] }}>Array API</Box>;
<Box
sx={{
fontSize: {
xs: 12,
sm: 18,
md: 24,
},
}}
>
Object API
</Box>;
}
function GapTest() {
<Box
sx={{
width: '100%',
display: 'flex',
alignItems: 'center',
flex: '1 0',
gap: '16px',
}}
>
Gap
</Box>;
}
function ComponentPropTest() {
<Box component="img" src="https://mui.com/" alt="Material UI" />;
<Box component={Test} test="Test string" />;
}
function ThemeCallbackTest() {
<Box sx={{ background: (theme) => theme.palette.primary.main }} />;
<Box sx={{ '&:hover': (theme) => ({ background: theme.palette.primary.main }) }} />;
<Box sx={{ '& .some-class': (theme) => ({ background: theme.palette.primary.main }) }} />;
<Box maxWidth={(theme) => theme.breakpoints.values.sm} />;
}
function CssVariablesWithNestedSelectors() {
<Box
sx={(theme) => ({
'--mui-palette-primary-main': '#FF0000',
})}
/>;
<Box
sx={(theme) => ({
'--mui-palette-primary-main': '#FF0000',
'&:hover': {
backgroundColor: theme.palette.primary.main,
'--mui-palette-primary-main': (t) => theme.palette.primary.main,
'--mui-spacing': (t) => theme.shape.borderRadius,
},
})}
/>;
<Box
sx={{
'--mui-palette-primary-main': '#FF0000',
'&:hover': {
backgroundColor: '#EE0000',
},
}}
/>;
<Box
sx={{
'--mui-palette-primary-main': '#FF0000',
'& .foo-bar': {
backgroundColor: '#EE0000',
},
}}
/>;
}
// The fill prop conflicts with the Array's fill function.
// This test ensures that the callback value inside the sx prop
// can be used without conflicting with the Array's fill function
function TestFillPropCallback() {
<Box
sx={{
fill: (theme) => theme.palette.primary.main,
}}
/>;
<Box
sx={[
{
fill: (theme) => theme.palette.primary.main,
},
]}
/>;
}
// eslint-disable-next-line material-ui/no-styled-box
const StyledBox = styled(Box)`
color: white;
` as typeof Box;
function StyledBoxWithSx() {
return (
<StyledBox component="span" sx={{ width: 300 }}>
Box
</StyledBox>
);
}
function LogicalPropertiesTest() {
<Box marginInline={1} paddingBlockEnd="10px" />;
}

View File

@@ -0,0 +1,303 @@
import * as React from 'react';
import { expect } from 'chai';
import { createRenderer, screen, isJsdom } from '@mui/internal-test-utils';
import { Box, ThemeProvider, boxClasses as classes } from '@mui/system';
import createTheme from '@mui/system/createTheme';
import describeConformance from '../../test/describeConformance';
describe('<Box />', () => {
const { render } = createRenderer();
describeConformance(<Box />, () => ({
classes,
render,
inheritComponent: 'div',
skip: [
'componentProp',
'componentsProp',
'rootClass',
'themeVariants',
'themeStyleOverrides',
'themeDefaultProps',
],
refInstanceof: window.HTMLDivElement,
}));
const testChildren = (
<div data-testid="child" className="unique">
Hello World
</div>
);
it('does not forward style props as DOM attributes', () => {
const elementRef = React.createRef();
render(
<Box
color="primary.main"
fontFamily="Comic Sans"
fontSize={{ xs: 'h6.fontSize', sm: 'h4.fontSize', md: 'h3.fontSize' }}
ref={elementRef}
/>,
);
const { current: element } = elementRef;
expect(element).not.to.have.attribute('color');
expect(element).not.to.have.attribute('font-family');
expect(element).not.to.have.attribute('font-size');
});
it('renders children and box content', () => {
render(
<Box data-testid="box" component="span" sx={{ m: 1 }}>
{testChildren}
</Box>,
);
const box = screen.getByTestId('box');
expect(box).contain(screen.getByTestId('child'));
expect(box.tagName).to.equal('SPAN');
});
it.skipIf(isJsdom())('respect properties order when generating the CSS', function test() {
const { container: testCaseBorderColorWins } = render(
<Box border={1} borderColor="rgb(0, 0, 255)" />,
);
expect(testCaseBorderColorWins.firstChild).toHaveComputedStyle({
borderTopWidth: '1px',
borderRightWidth: '1px',
borderBottomWidth: '1px',
borderLeftWidth: '1px',
borderTopStyle: 'solid',
borderRightStyle: 'solid',
borderBottomStyle: 'solid',
borderLeftStyle: 'solid',
borderTopColor: 'rgb(0, 0, 255)',
borderRightColor: 'rgb(0, 0, 255)',
borderBottomColor: 'rgb(0, 0, 255)',
borderLeftColor: 'rgb(0, 0, 255)',
});
const { container: testCaseBorderWins } = render(
<Box borderColor={'rgb(0, 0, 255)'} border={1} />,
);
expect(testCaseBorderWins.firstChild).toHaveComputedStyle({
borderTopWidth: '1px',
borderRightWidth: '1px',
borderBottomWidth: '1px',
borderLeftWidth: '1px',
borderTopStyle: 'solid',
borderRightStyle: 'solid',
borderBottomStyle: 'solid',
borderLeftStyle: 'solid',
borderTopColor: 'rgb(0, 0, 0)',
borderRightColor: 'rgb(0, 0, 0)',
borderBottomColor: 'rgb(0, 0, 0)',
borderLeftColor: 'rgb(0, 0, 0)',
});
});
it.skipIf(isJsdom())(
'respect border-*-color properties order when generating the CSS',
function test() {
const { container: testCaseBorderPositionColorWins } = render(
<Box
borderTop={1}
borderTopColor="rgb(0, 0, 25)"
borderRight={2}
borderRightColor="rgb(0, 0, 50)"
borderBottom={3}
borderBottomColor="rgb(0, 0, 75)"
borderLeft={4}
borderLeftColor="rgb(0, 0, 100)"
/>,
);
expect(testCaseBorderPositionColorWins.firstChild).toHaveComputedStyle({
borderTopWidth: '1px',
borderRightWidth: '2px',
borderBottomWidth: '3px',
borderLeftWidth: '4px',
borderTopStyle: 'solid',
borderRightStyle: 'solid',
borderBottomStyle: 'solid',
borderLeftStyle: 'solid',
borderTopColor: 'rgb(0, 0, 25)',
borderRightColor: 'rgb(0, 0, 50)',
borderBottomColor: 'rgb(0, 0, 75)',
borderLeftColor: 'rgb(0, 0, 100)',
});
const { container: testCaseBorderPositionWins } = render(
<Box
borderTopColor="rgb(0, 0, 25)"
borderTop={1}
borderRightColor="rgb(0, 0, 50)"
borderRight={2}
borderBottomColor="rgb(0, 0, 75)"
borderBottom={3}
borderLeftColor="rgb(0, 0, 100)"
borderLeft={4}
/>,
);
expect(testCaseBorderPositionWins.firstChild).toHaveComputedStyle({
borderTopWidth: '1px',
borderRightWidth: '2px',
borderBottomWidth: '3px',
borderLeftWidth: '4px',
borderTopStyle: 'solid',
borderRightStyle: 'solid',
borderBottomStyle: 'solid',
borderLeftStyle: 'solid',
borderTopColor: 'rgb(0, 0, 0)',
borderRightColor: 'rgb(0, 0, 0)',
borderBottomColor: 'rgb(0, 0, 0)',
borderLeftColor: 'rgb(0, 0, 0)',
});
},
);
it.skipIf(isJsdom())(
'respect properties order when generating the CSS from the sx prop',
function test() {
const { container: testCaseBorderColorWins } = render(
<Box sx={{ border: 1, borderColor: 'rgb(0, 0, 255)' }} />,
);
expect(testCaseBorderColorWins.firstChild).toHaveComputedStyle({
borderTopWidth: '1px',
borderRightWidth: '1px',
borderBottomWidth: '1px',
borderLeftWidth: '1px',
borderTopStyle: 'solid',
borderRightStyle: 'solid',
borderBottomStyle: 'solid',
borderLeftStyle: 'solid',
borderTopColor: 'rgb(0, 0, 255)',
borderRightColor: 'rgb(0, 0, 255)',
borderBottomColor: 'rgb(0, 0, 255)',
borderLeftColor: 'rgb(0, 0, 255)',
});
const { container: testCaseBorderWins } = render(
<Box sx={{ borderColor: 'rgb(0, 0, 255)', border: 1 }} />,
);
expect(testCaseBorderWins.firstChild).toHaveComputedStyle({
borderTopWidth: '1px',
borderRightWidth: '1px',
borderBottomWidth: '1px',
borderLeftWidth: '1px',
borderTopStyle: 'solid',
borderRightStyle: 'solid',
borderBottomStyle: 'solid',
borderLeftStyle: 'solid',
borderTopColor: 'rgb(0, 0, 0)',
borderRightColor: 'rgb(0, 0, 0)',
borderBottomColor: 'rgb(0, 0, 0)',
borderLeftColor: 'rgb(0, 0, 0)',
});
const { container: testCaseBorderPositionColorWins } = render(
<Box
sx={{
borderTop: 1,
borderTopColor: 'rgb(0, 0, 25)',
borderRight: 2,
borderRightColor: 'rgb(0, 0, 50)',
borderBottom: 3,
borderBottomColor: 'rgb(0, 0, 75)',
borderLeft: 4,
borderLeftColor: 'rgb(0, 0, 100)',
}}
/>,
);
expect(testCaseBorderPositionColorWins.firstChild).toHaveComputedStyle({
borderTopWidth: '1px',
borderRightWidth: '2px',
borderBottomWidth: '3px',
borderLeftWidth: '4px',
borderTopStyle: 'solid',
borderRightStyle: 'solid',
borderBottomStyle: 'solid',
borderLeftStyle: 'solid',
borderTopColor: 'rgb(0, 0, 25)',
borderRightColor: 'rgb(0, 0, 50)',
borderBottomColor: 'rgb(0, 0, 75)',
borderLeftColor: 'rgb(0, 0, 100)',
});
const { container: testCaseBorderPositionWins } = render(
<Box
sx={{
borderTopColor: 'rgb(0, 0, 25)',
borderTop: 1,
borderRightColor: 'rgb(0, 0, 50)',
borderRight: 2,
borderBottomColor: 'rgb(0, 0, 75)',
borderBottom: 3,
borderLeftColor: 'rgb(0, 0, 100)',
borderLeft: 4,
}}
/>,
);
expect(testCaseBorderPositionWins.firstChild).toHaveComputedStyle({
borderTopWidth: '1px',
borderRightWidth: '2px',
borderBottomWidth: '3px',
borderLeftWidth: '4px',
borderTopStyle: 'solid',
borderRightStyle: 'solid',
borderBottomStyle: 'solid',
borderLeftStyle: 'solid',
borderTopColor: 'rgb(0, 0, 0)',
borderRightColor: 'rgb(0, 0, 0)',
borderBottomColor: 'rgb(0, 0, 0)',
borderLeftColor: 'rgb(0, 0, 0)',
});
},
);
it('combines system properties with the sx prop', () => {
const { container } = render(<Box mt={2} mr={1} sx={{ marginRight: 5, mb: 2 }} />);
expect(container.firstChild).toHaveComputedStyle({
marginTop: '16px',
marginRight: '40px',
marginBottom: '16px',
});
});
it('adds the utility mui class', () => {
render(<Box data-testid="regular-box" />);
expect(screen.getByTestId('regular-box')).to.have.class('MuiBox-root');
});
describe('prop: maxWidth', () => {
it.skipIf(isJsdom())('should resolve breakpoints with custom units', function test() {
const theme = createTheme({
breakpoints: {
unit: 'rem',
values: {
xs: 10,
},
},
});
const { container } = render(
<ThemeProvider theme={theme}>
<Box maxWidth="xs" />,
</ThemeProvider>,
);
expect(container.firstChild).toHaveComputedStyle({
// 10rem x 16px = 160px
maxWidth: '160px',
});
});
});
});

View File

@@ -0,0 +1,12 @@
import generateUtilityClasses from '@mui/utils/generateUtilityClasses';
export interface BoxClasses {
/** Styles applied to the root element. */
root: string;
}
export type BoxClassKey = keyof BoxClasses;
const boxClasses: BoxClasses = generateUtilityClasses('MuiBox', ['root']);
export default boxClasses;

View File

@@ -0,0 +1,5 @@
export { default } from './Box';
export * from './Box';
export { default as boxClasses } from './boxClasses';
export * from './boxClasses';

View File

@@ -0,0 +1,4 @@
export { default } from './Box';
export { default as boxClasses } from './boxClasses';
export * from './boxClasses';

View File

@@ -0,0 +1,9 @@
import * as CSS from 'csstype';
/**
* All non-vendor-prefixed CSS properties. (Also allows `number` in order to support CSS-in-JS libs,
* since they are converted to `px`.)
*/
export interface CSSProperties
extends CSS.StandardProperties<number | string>,
CSS.SvgProperties<number | string> {}

View File

@@ -0,0 +1,33 @@
import { expect } from 'chai';
import { createRenderer } from '@mui/internal-test-utils';
import { Container, containerClasses as classes } from '@mui/system';
import describeConformance from '../../test/describeConformance';
describe('<Container />', () => {
const { render } = createRenderer();
const defaultProps = {
children: <div />,
};
describeConformance(<Container {...defaultProps} />, () => ({
classes,
inheritComponent: 'div',
render,
refInstanceof: window.HTMLElement,
muiName: 'MuiContainer',
skip: ['componentsProp'],
testVariantProps: { fixed: true },
}));
describe('prop: maxWidth', () => {
it('should support different maxWidth values', () => {
const { container: firstContainer } = render(<Container {...defaultProps} />);
expect(firstContainer.firstChild).to.have.class(classes.maxWidthLg);
const { container: secondsContainer } = render(
<Container {...defaultProps} maxWidth={false} />,
);
expect(secondsContainer.firstChild).not.to.have.class(classes.maxWidthLg);
});
});
});

View File

@@ -0,0 +1,69 @@
'use client';
import PropTypes from 'prop-types';
import createContainer from './createContainer';
/**
*
* Demos:
*
* - [Container (Material UI)](https://mui.com/material-ui/react-container/)
* - [Container (MUI System)](https://mui.com/system/react-container/)
*
* API:
*
* - [Container API](https://mui.com/system/api/container/)
*/
const Container = createContainer();
Container.propTypes /* remove-proptypes */ = {
// ┌────────────────────────────── Warning ──────────────────────────────┐
// │ These PropTypes are generated from the TypeScript type definitions. │
// │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
// └─────────────────────────────────────────────────────────────────────┘
/**
* @ignore
*/
children: PropTypes.node,
/**
* Override or extend the styles applied to the component.
*/
classes: PropTypes.object,
/**
* The component used for the root node.
* Either a string to use a HTML element or a component.
*/
component: PropTypes.elementType,
/**
* If `true`, the left and right padding is removed.
* @default false
*/
disableGutters: PropTypes.bool,
/**
* Set the max-width to match the min-width of the current breakpoint.
* This is useful if you'd prefer to design for a fixed set of sizes
* instead of trying to accommodate a fully fluid viewport.
* It's fluid by default.
* @default false
*/
fixed: PropTypes.bool,
/**
* Determine the max-width of the container.
* The container width grows with the size of the screen.
* Set to `false` to disable `maxWidth`.
* @default 'lg'
*/
maxWidth: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.oneOf(['xs', 'sm', 'md', 'lg', 'xl', false]),
PropTypes.string,
]),
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])),
PropTypes.func,
PropTypes.object,
]),
} as any;
export default Container;

View File

@@ -0,0 +1,48 @@
import * as React from 'react';
import { OverrideProps } from '@mui/types';
import { SxProps } from '../styleFunctionSx';
import { Theme, Breakpoint } from '../createTheme';
import { ContainerClasses } from './containerClasses';
export interface ContainerTypeMap<
AdditionalProps = {},
DefaultComponent extends React.ElementType = 'div',
> {
props: AdditionalProps & {
children?: React.ReactNode;
/**
* Override or extend the styles applied to the component.
*/
classes?: Partial<ContainerClasses>;
/**
* If `true`, the left and right padding is removed.
* @default false
*/
disableGutters?: boolean;
/**
* Set the max-width to match the min-width of the current breakpoint.
* This is useful if you'd prefer to design for a fixed set of sizes
* instead of trying to accommodate a fully fluid viewport.
* It's fluid by default.
* @default false
*/
fixed?: boolean;
/**
* Determine the max-width of the container.
* The container width grows with the size of the screen.
* Set to `false` to disable `maxWidth`.
* @default 'lg'
*/
maxWidth?: Breakpoint | false;
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx?: SxProps<Theme>;
};
defaultComponent: DefaultComponent;
}
export type ContainerProps<
RootComponent extends React.ElementType = ContainerTypeMap['defaultComponent'],
AdditionalProps = {},
> = OverrideProps<ContainerTypeMap<AdditionalProps, RootComponent>, RootComponent>;

View File

@@ -0,0 +1,40 @@
import generateUtilityClasses from '@mui/utils/generateUtilityClasses';
import generateUtilityClass from '@mui/utils/generateUtilityClass';
export interface ContainerClasses {
/** Styles applied to the root element. */
root: string;
/** Styles applied to the root element if `disableGutters={true}`. */
disableGutters: string;
/** Styles applied to the root element if `fixed={true}`. */
fixed: string;
/** Styles applied to the root element if `maxWidth="xs"`. */
maxWidthXs: string;
/** Styles applied to the root element if `maxWidth="sm"`. */
maxWidthSm: string;
/** Styles applied to the root element if `maxWidth="md"`. */
maxWidthMd: string;
/** Styles applied to the root element if `maxWidth="lg"`. */
maxWidthLg: string;
/** Styles applied to the root element if `maxWidth="xl"`. */
maxWidthXl: string;
}
export type ContainerClassKey = keyof ContainerClasses;
export function getContainerUtilityClass(slot: string): string {
return generateUtilityClass('MuiContainer', slot);
}
const containerClasses: ContainerClasses = generateUtilityClasses('MuiContainer', [
'root',
'disableGutters',
'fixed',
'maxWidthXs',
'maxWidthSm',
'maxWidthMd',
'maxWidthLg',
'maxWidthXl',
]);
export default containerClasses;

View File

@@ -0,0 +1,183 @@
'use client';
import * as React from 'react';
import PropTypes from 'prop-types';
import clsx from 'clsx';
import { Interpolation, MUIStyledComponent as StyledComponent } from '@mui/styled-engine';
import { OverridableComponent } from '@mui/types';
import generateUtilityClass from '@mui/utils/generateUtilityClass';
import composeClasses from '@mui/utils/composeClasses';
import capitalize from '@mui/utils/capitalize';
import { ContainerProps, ContainerTypeMap } from './ContainerProps';
import useThemePropsSystem from '../useThemeProps';
import systemStyled from '../styled';
import createTheme, { Theme as DefaultTheme, Breakpoint } from '../createTheme';
interface StyleFnProps<Theme> extends ContainerProps {
theme: Theme;
ownerState: ContainerProps;
}
const defaultTheme = createTheme();
const defaultCreateStyledComponent = systemStyled('div', {
name: 'MuiContainer',
slot: 'Root',
overridesResolver: (props, styles) => {
const { ownerState } = props;
return [
styles.root,
styles[`maxWidth${capitalize(String(ownerState.maxWidth))}`],
ownerState.fixed && styles.fixed,
ownerState.disableGutters && styles.disableGutters,
];
},
});
const useThemePropsDefault = (inProps: ContainerProps) =>
useThemePropsSystem({ props: inProps, name: 'MuiContainer', defaultTheme });
const useUtilityClasses = (ownerState: ContainerProps, componentName: string) => {
const getContainerUtilityClass = (slot: string) => {
return generateUtilityClass(componentName, slot);
};
const { classes, fixed, disableGutters, maxWidth } = ownerState;
const slots = {
root: [
'root',
maxWidth && `maxWidth${capitalize(String(maxWidth))}`,
fixed && 'fixed',
disableGutters && 'disableGutters',
],
};
return composeClasses(slots, getContainerUtilityClass, classes);
};
type RequiredThemeStructure = Pick<DefaultTheme, 'breakpoints' | 'spacing'>;
export default function createContainer<Theme extends RequiredThemeStructure = DefaultTheme>(
options: {
createStyledComponent?: (
...styles: Array<Interpolation<StyleFnProps<Theme>>>
) => StyledComponent<ContainerProps>;
useThemeProps?: (inProps: ContainerProps) => ContainerProps & { component?: React.ElementType };
componentName?: string;
} = {},
) {
const {
// This will allow adding custom styled fn (for example for custom sx style function)
createStyledComponent = defaultCreateStyledComponent,
useThemeProps = useThemePropsDefault,
componentName = 'MuiContainer',
} = options;
const ContainerRoot = createStyledComponent(
({ theme, ownerState }: StyleFnProps<Theme>) =>
({
width: '100%',
marginLeft: 'auto',
boxSizing: 'border-box',
marginRight: 'auto',
...(!ownerState.disableGutters && {
paddingLeft: theme.spacing(2),
paddingRight: theme.spacing(2),
// @ts-ignore module augmentation fails if custom breakpoints are used
[theme.breakpoints.up('sm')]: {
paddingLeft: theme.spacing(3),
paddingRight: theme.spacing(3),
},
}),
}) as Interpolation<StyleFnProps<Theme>>,
({ theme, ownerState }: StyleFnProps<Theme>) =>
ownerState.fixed &&
Object.keys(theme.breakpoints.values).reduce((acc, breakpointValueKey) => {
const breakpoint = breakpointValueKey;
const value = theme.breakpoints.values[breakpoint as Breakpoint];
if (value !== 0) {
// @ts-ignore
acc[theme.breakpoints.up(breakpoint)] = {
maxWidth: `${value}${theme.breakpoints.unit}`,
};
}
return acc;
}, {}),
({ theme, ownerState }: StyleFnProps<Theme>) => ({
// @ts-ignore module augmentation fails if custom breakpoints are used
...(ownerState.maxWidth === 'xs' && {
// @ts-ignore module augmentation fails if custom breakpoints are used
[theme.breakpoints.up('xs')]: {
// @ts-ignore module augmentation fails if custom breakpoints are used
maxWidth: Math.max(theme.breakpoints.values.xs, 444),
},
}),
...(ownerState.maxWidth &&
// @ts-ignore module augmentation fails if custom breakpoints are used
ownerState.maxWidth !== 'xs' && {
// @ts-ignore module augmentation fails if custom breakpoints are used
[theme.breakpoints.up(ownerState.maxWidth)]: {
// @ts-ignore module augmentation fails if custom breakpoints are used
maxWidth: `${theme.breakpoints.values[ownerState.maxWidth]}${theme.breakpoints.unit}`,
},
}),
}),
);
const Container = React.forwardRef(function Container(inProps, ref) {
const props: ContainerProps & { component?: React.ElementType } = useThemeProps(inProps);
const {
className,
component = 'div',
disableGutters = false,
fixed = false,
maxWidth = 'lg',
classes: classesProp,
...other
} = props;
const ownerState = {
...props,
component,
disableGutters,
fixed,
maxWidth,
};
// @ts-ignore module augmentation fails if custom breakpoints are used
const classes = useUtilityClasses(ownerState, componentName);
return (
// @ts-ignore theme is injected by the styled util
<ContainerRoot
as={component}
// @ts-ignore module augmentation fails if custom breakpoints are used
ownerState={ownerState}
className={clsx(classes.root, className)}
ref={ref}
{...other}
/>
);
}) as OverridableComponent<ContainerTypeMap>;
Container.propTypes /* remove-proptypes */ = {
children: PropTypes.node,
classes: PropTypes.object,
className: PropTypes.string,
component: PropTypes.elementType,
disableGutters: PropTypes.bool,
fixed: PropTypes.bool,
maxWidth: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.oneOf(['xs', 'sm', 'md', 'lg', 'xl', false]),
PropTypes.string,
]),
sx: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])),
PropTypes.func,
PropTypes.object,
]),
};
return Container;
}

View File

@@ -0,0 +1,5 @@
export { default } from './Container';
export * from './ContainerProps';
export { default as containerClasses } from './containerClasses';
export * from './containerClasses';

View File

@@ -0,0 +1,4 @@
export { default } from './Container';
export { default as containerClasses } from './containerClasses';
export * from './containerClasses';

View File

@@ -0,0 +1,63 @@
'use client';
import * as React from 'react';
import PropTypes from 'prop-types';
import resolveProps from '@mui/utils/resolveProps';
const PropsContext = React.createContext<Record<string, any> | undefined>(undefined);
function DefaultPropsProvider({
value,
children,
}: React.PropsWithChildren<{ value: Record<string, any> | undefined }>) {
return <PropsContext.Provider value={value}>{children}</PropsContext.Provider>;
}
DefaultPropsProvider.propTypes /* remove-proptypes */ = {
// ┌────────────────────────────── Warning ──────────────────────────────┐
// │ These PropTypes are generated from the TypeScript type definitions. │
// │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
// └─────────────────────────────────────────────────────────────────────┘
/**
* @ignore
*/
children: PropTypes.node,
/**
* @ignore
*/
value: PropTypes.object,
} as any;
function getThemeProps<
Theme extends {
components?: Record<string, { defaultProps?: any; styleOverrides?: any; variants?: any }> & {
mergeClassNameAndStyle?: boolean;
};
},
Props,
Name extends string,
>(params: { props: Props; name: Name; theme?: Theme }): Props {
const { theme, name, props } = params;
if (!theme || !theme.components || !theme.components[name]) {
return props;
}
const config = theme.components[name];
if (config.defaultProps) {
// compatible with v5 signature
return resolveProps(config.defaultProps, props, theme.components.mergeClassNameAndStyle);
}
if (!config.styleOverrides && !config.variants) {
// v6 signature, no property 'defaultProps'
return resolveProps(config as any, props, theme.components.mergeClassNameAndStyle);
}
return props;
}
export function useDefaultProps<Props>({ props, name }: { props: Props; name: string }) {
const ctx = React.useContext(PropsContext);
return getThemeProps({ props, name, theme: { components: ctx } });
}
export default DefaultPropsProvider;

View File

@@ -0,0 +1 @@
export { default, useDefaultProps } from './DefaultPropsProvider';

View File

@@ -0,0 +1,11 @@
import { expect } from 'chai';
import { createRenderer } from '@mui/internal-test-utils';
import { GlobalStyles } from '@mui/system';
describe('<GlobalStyles />', () => {
const { render } = createRenderer();
it('should work', () => {
expect(() => render(<GlobalStyles styles={{}} />)).not.to.throw();
});
});

View File

@@ -0,0 +1,80 @@
'use client';
import PropTypes from 'prop-types';
import {
GlobalStyles as MuiGlobalStyles,
Interpolation,
internal_serializeStyles as serializeStyles,
} from '@mui/styled-engine';
import useTheme from '../useTheme';
import { Theme as SystemTheme } from '../createTheme';
export interface GlobalStylesProps<Theme = SystemTheme> {
styles: Interpolation<Theme>;
defaultTheme?: object;
themeId?: string;
}
function wrapGlobalLayer(styles: any) {
const serialized = serializeStyles(styles) as { styles?: string };
if (styles !== serialized && serialized.styles) {
if (!serialized.styles.match(/^@layer\s+[^{]*$/)) {
// If the styles are not already wrapped in a layer, wrap them in a global layer.
serialized.styles = `@layer global{${serialized.styles}}`;
}
return serialized;
}
return styles;
}
function GlobalStyles<Theme = SystemTheme>({
styles,
themeId,
defaultTheme = {},
}: GlobalStylesProps<Theme>) {
const upperTheme = useTheme(defaultTheme);
const resolvedTheme = themeId ? (upperTheme as any)[themeId] || upperTheme : upperTheme;
let globalStyles = typeof styles === 'function' ? styles(resolvedTheme) : styles;
if (resolvedTheme.modularCssLayers) {
if (Array.isArray(globalStyles)) {
globalStyles = globalStyles.map((styleArg) => {
if (typeof styleArg === 'function') {
return wrapGlobalLayer(styleArg(resolvedTheme));
}
return wrapGlobalLayer(styleArg);
});
} else {
globalStyles = wrapGlobalLayer(globalStyles);
}
}
return <MuiGlobalStyles styles={globalStyles as any} />;
}
GlobalStyles.propTypes /* remove-proptypes */ = {
// ┌────────────────────────────── Warning ──────────────────────────────┐
// │ These PropTypes are generated from the TypeScript type definitions. │
// │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
// └─────────────────────────────────────────────────────────────────────┘
/**
* @ignore
*/
defaultTheme: PropTypes.object,
/**
* @ignore
*/
styles: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.array,
PropTypes.func,
PropTypes.number,
PropTypes.object,
PropTypes.string,
PropTypes.bool,
]),
/**
* @ignore
*/
themeId: PropTypes.string,
} as any;
export default GlobalStyles;

View File

@@ -0,0 +1,2 @@
export { default } from './GlobalStyles';
export * from './GlobalStyles';

View File

@@ -0,0 +1,283 @@
import { expect } from 'chai';
import { createRenderer, screen, isJsdom } from '@mui/internal-test-utils';
import { ThemeProvider } from '@mui/system';
import createTheme from '@mui/system/createTheme';
import Grid, { gridClasses as classes } from '@mui/system/Grid';
import describeConformance from '../../test/describeConformance';
describe('System <Grid />', () => {
const { render } = createRenderer();
const defaultProps = {
children: <div />,
};
describeConformance(<Grid {...defaultProps} />, () => ({
classes,
inheritComponent: 'div',
render,
ThemeProvider,
refInstanceof: window.HTMLElement,
muiName: 'MuiGrid',
testVariantProps: { container: true, spacing: 5 },
skip: ['componentsProp', 'classesRoot'],
}));
describe('prop: container', () => {
it('should apply the container class', () => {
const { container } = render(<Grid container />);
expect(container.firstChild).to.have.class(classes.container);
});
});
describe('prop: xs', () => {
it('should apply the flex-grow class', () => {
const { container } = render(<Grid size="grow" />);
expect(container.firstChild).to.have.class(classes['grid-xs-grow']);
});
it('should apply the flex size class', () => {
const { container } = render(<Grid size={3} />);
expect(container.firstChild).to.have.class(classes['grid-xs-3']);
});
it('should apply the flex auto class', () => {
const { container } = render(<Grid size="auto" />);
expect(container.firstChild).to.have.class(classes['grid-xs-auto']);
});
// Need full CSS resolution
it.skipIf(isJsdom())(
'should apply the styles necessary for variable width nested item when set to auto',
function test() {
render(
<Grid container>
<Grid container data-testid="auto" size="auto">
<div style={{ width: '300px' }} />
</Grid>
<Grid size={11} />
</Grid>,
);
expect(screen.getByTestId('auto')).toHaveComputedStyle({
flexBasis: 'auto',
flexGrow: '0',
flexShrink: '0',
maxWidth: 'none',
width: '300px',
});
},
);
});
describe('prop: spacing', () => {
it('should have a spacing', () => {
const { container } = render(<Grid container spacing={1} />);
expect(container.firstChild).to.have.class(classes['spacing-xs-1']);
});
it('should not support undefined values', () => {
const { container } = render(
<Grid container>
<Grid data-testid="child" />
</Grid>,
);
expect(container.firstChild).not.to.have.class('MuiGrid-spacing-xs-undefined');
});
it('should not support zero values', () => {
const { container } = render(
<Grid container spacing={0}>
<Grid data-testid="child" />
</Grid>,
);
expect(container.firstChild).not.to.have.class('MuiGrid-spacing-xs-0');
});
it('should support object values', () => {
const { container } = render(
<Grid container spacing={{ sm: 1.5, md: 2 }}>
<Grid data-testid="child" />
</Grid>,
);
expect(container.firstChild).to.have.class('MuiGrid-spacing-sm-1.5');
expect(container.firstChild).to.have.class('MuiGrid-spacing-md-2');
});
it('should ignore object values of zero', () => {
const { container } = render(
<Grid container spacing={{ sm: 0, md: 2 }}>
<Grid data-testid="child" />
</Grid>,
);
expect(container.firstChild).not.to.have.class('MuiGrid-spacing-sm-0');
expect(container.firstChild).to.have.class('MuiGrid-spacing-md-2');
});
});
describe('spacing', () => {
it.skipIf(isJsdom())('should generate the right values', function test() {
const parentWidth = 500;
const remValue = 16;
const remTheme = createTheme({
spacing: (factor) => `${0.25 * factor}rem`,
});
const view = render(
<div style={{ width: parentWidth }}>
<ThemeProvider theme={remTheme}>
<Grid data-testid="grid" container spacing={2}>
<Grid data-testid="first-custom-theme" />
<Grid />
</Grid>
</ThemeProvider>
</div>,
);
expect(screen.getByTestId('grid')).toHaveComputedStyle({
rowGap: `${0.5 * remValue}px`, // 0.5rem
columnGap: `${0.5 * remValue}px`, // 0.5rem
});
view.rerender(
<div style={{ width: parentWidth }}>
<Grid data-testid="grid" container spacing={2}>
<Grid data-testid="first-default-theme" />
<Grid />
</Grid>
</div>,
);
expect(screen.getByTestId('grid')).toHaveComputedStyle({
rowGap: '16px',
columnGap: '16px',
});
});
});
it('combines system properties with the sx prop', () => {
const { container } = render(<Grid mt={2} mr={1} sx={{ marginRight: 5, mb: 2 }} />);
expect(container.firstChild).toHaveComputedStyle({
marginTop: '16px',
marginRight: '40px',
marginBottom: '16px',
});
});
describe('prop: wrap', () => {
it('should wrap by default', () => {
render(<Grid container data-testid="wrap" />);
expect(screen.getByTestId('wrap')).toHaveComputedStyle({
flexWrap: 'wrap',
});
});
it('should apply nowrap class and style', () => {
const view = render(<Grid container wrap="nowrap" data-testid="wrap" />);
expect(view.container.firstChild).to.have.class('MuiGrid-wrap-xs-nowrap');
expect(screen.getByTestId('wrap')).toHaveComputedStyle({
flexWrap: 'nowrap',
});
});
it('should apply wrap-reverse class and style', () => {
const view = render(<Grid container wrap="wrap-reverse" data-testid="wrap" />);
expect(view.container.firstChild).to.have.class('MuiGrid-wrap-xs-wrap-reverse');
expect(screen.getByTestId('wrap')).toHaveComputedStyle({
flexWrap: 'wrap-reverse',
});
});
});
describe('Custom breakpoints', () => {
it('should apply the custom breakpoint class', () => {
const { container } = render(
<ThemeProvider
theme={createTheme({
breakpoints: {
values: {
mobile: 0,
tablet: 640,
laptop: 1024,
},
},
})}
>
{/* `lg` is to mimic mistake, it is not a breakpoint anymore */}
<Grid
size={{
mobile: 2,
tablet: 3,
laptop: 'auto',
}}
/>
</ThemeProvider>,
);
expect(container.firstChild).to.have.class('MuiGrid-grid-mobile-2');
expect(container.firstChild).to.have.class('MuiGrid-grid-tablet-3');
expect(container.firstChild).to.have.class('MuiGrid-grid-laptop-auto');
// The grid should not have class for `lg` prop
expect(container.firstChild).not.to.have.class('MuiGrid-grid-lg-5');
});
it('should apply the custom breakpoint spacing class', () => {
const { container } = render(
<ThemeProvider
theme={createTheme({
breakpoints: {
values: {
mobile: 0,
tablet: 640,
laptop: 1024,
},
},
})}
>
<Grid container spacing={2} />
<Grid container spacing={{ tablet: 2, laptop: 4 }} />
</ThemeProvider>,
);
expect(container.firstChild).to.have.class('MuiGrid-spacing-mobile-2');
expect(container.lastChild).to.have.class('MuiGrid-spacing-tablet-2');
expect(container.lastChild).to.have.class('MuiGrid-spacing-laptop-4');
});
});
describe('legacy Grid component warnings', () => {
it('should warn once if the `item` prop is used', () => {
expect(() => {
render(<Grid item />);
}).toWarnDev(
'MUI Grid: The `item` prop has been removed and is no longer necessary. You can safely remove it.',
);
// Should not warn again
expect(() => {
render(<Grid item />);
}).not.toWarnDev();
});
it('should warn if the `zeroMinWidth` prop is used', () => {
expect(() => {
render(<Grid zeroMinWidth />);
}).toWarnDev(
'MUI Grid: The `zeroMinWidth` prop has been removed and is no longer necessary. You can safely remove it.',
);
});
createTheme({}).breakpoints.keys.forEach((breakpoint) => {
it(`should warn if the \`${breakpoint}\` prop is used`, () => {
expect(() => {
render(<Grid {...{ [breakpoint]: 8 }} />);
}).toWarnDev(
// #host-reference
`MUI Grid: The \`${breakpoint}\` prop has been removed. See https://mui.com/material-ui/migration/upgrade-to-grid-v2/ for migration instructions.`,
);
});
});
});
});

View File

@@ -0,0 +1,145 @@
'use client';
import PropTypes from 'prop-types';
import createGrid from './createGrid';
/**
*
* Demos:
*
* - [Grid (Joy UI)](https://mui.com/joy-ui/react-grid/)
* - [Grid (Material UI)](https://mui.com/material-ui/react-grid/)
*
* API:
*
* - [Grid API](https://mui.com/system/api/grid/)
*/
const Grid = createGrid();
Grid.propTypes /* remove-proptypes */ = {
// ┌────────────────────────────── Warning ──────────────────────────────┐
// │ These PropTypes are generated from the TypeScript type definitions. │
// │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
// └─────────────────────────────────────────────────────────────────────┘
/**
* The content of the component.
*/
children: PropTypes.node,
/**
* The number of columns.
* @default 12
*/
columns: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.arrayOf(PropTypes.number),
PropTypes.number,
PropTypes.object,
]),
/**
* Defines the horizontal space between the type `item` components.
* It overrides the value of the `spacing` prop.
*/
columnSpacing: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])),
PropTypes.number,
PropTypes.object,
PropTypes.string,
]),
/**
* If `true`, the component will have the flex *container* behavior.
* You should be wrapping *items* with a *container*.
* @default false
*/
container: PropTypes.bool,
/**
* Defines the `flex-direction` style property.
* It is applied for all screen sizes.
* @default 'row'
*/
direction: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.oneOf(['column-reverse', 'column', 'row-reverse', 'row']),
PropTypes.arrayOf(PropTypes.oneOf(['column-reverse', 'column', 'row-reverse', 'row'])),
PropTypes.object,
]),
/**
* Defines the offset value for the type `item` components.
*/
offset: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.string,
PropTypes.number,
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number])),
PropTypes.object,
]),
/**
* Defines the vertical space between the type `item` components.
* It overrides the value of the `spacing` prop.
*/
rowSpacing: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])),
PropTypes.number,
PropTypes.object,
PropTypes.string,
]),
/**
* Defines the size of the the type `item` components.
*/
size: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.string,
PropTypes.bool,
PropTypes.number,
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.bool, PropTypes.number])),
PropTypes.object,
]),
/**
* Defines the space between the type `item` components.
* It can only be used on a type `container` component.
* @default 0
*/
spacing: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])),
PropTypes.number,
PropTypes.object,
PropTypes.string,
]),
/**
* @ignore
*/
sx: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])),
PropTypes.func,
PropTypes.object,
]),
/**
* @internal
* The level of the grid starts from `0` and increases when the grid nests
* inside another grid. Nesting is defined as a container Grid being a direct
* child of a container Grid.
*
* ```js
* <Grid container> // level 0
* <Grid container> // level 1
* <Grid container> // level 2
* ```
*
* Only consecutive grid is considered nesting. A grid container will start at
* `0` if there are non-Grid container element above it.
*
* ```js
* <Grid container> // level 0
* <div>
* <Grid container> // level 0
* ```
*
* ```js
* <Grid container> // level 0
* <Grid>
* <Grid container> // level 0
* ```
*/
unstable_level: PropTypes.number,
/**
* Defines the `flex-wrap` style property.
* It's applied for all screen sizes.
* @default 'wrap'
*/
wrap: PropTypes.oneOf(['nowrap', 'wrap-reverse', 'wrap']),
} as any;
export default Grid;

View File

@@ -0,0 +1,116 @@
import * as React from 'react';
import { OverrideProps, PartiallyRequired } from '@mui/types';
import { SxProps } from '../styleFunctionSx';
import { Theme, Breakpoint } from '../createTheme';
import { SystemProps } from '../Box';
type ResponsiveStyleValue<T> = T | Array<T | null> | { [key in Breakpoint]?: T | null };
export type GridDirection = 'row' | 'row-reverse' | 'column' | 'column-reverse';
export type GridSpacing = number | string;
export type GridWrap = 'nowrap' | 'wrap' | 'wrap-reverse';
export type GridSize = 'auto' | 'grow' | number | false;
export type GridOffset = 'auto' | number;
export interface GridBaseProps {
/**
* The content of the component.
*/
children?: React.ReactNode;
/**
* The number of columns.
* @default 12
*/
columns?: ResponsiveStyleValue<number>;
/**
* Defines the horizontal space between the type `item` components.
* It overrides the value of the `spacing` prop.
*/
columnSpacing?: ResponsiveStyleValue<GridSpacing>;
/**
* If `true`, the component will have the flex *container* behavior.
* You should be wrapping *items* with a *container*.
* @default false
*/
container?: boolean;
/**
* Defines the `flex-direction` style property.
* It is applied for all screen sizes.
* @default 'row'
*/
direction?: ResponsiveStyleValue<GridDirection>;
/**
* Defines the offset value for the type `item` components.
*/
offset?: ResponsiveStyleValue<GridOffset>;
/**
* @internal
* The level of the grid starts from `0` and increases when the grid nests
* inside another grid. Nesting is defined as a container Grid being a direct
* child of a container Grid.
*
* ```js
* <Grid container> // level 0
* <Grid container> // level 1
* <Grid container> // level 2
* ```
*
* Only consecutive grid is considered nesting. A grid container will start at
* `0` if there are non-Grid container element above it.
*
* ```js
* <Grid container> // level 0
* <div>
* <Grid container> // level 0
* ```
*
* ```js
* <Grid container> // level 0
* <Grid>
* <Grid container> // level 0
* ```
*/
unstable_level?: number;
/**
* Defines the vertical space between the type `item` components.
* It overrides the value of the `spacing` prop.
*/
rowSpacing?: ResponsiveStyleValue<GridSpacing>;
/**
* Defines the size of the the type `item` components.
*/
size?: ResponsiveStyleValue<GridSize>;
/**
* Defines the space between the type `item` components.
* It can only be used on a type `container` component.
* @default 0
*/
spacing?: ResponsiveStyleValue<GridSpacing> | undefined;
/**
* Defines the `flex-wrap` style property.
* It's applied for all screen sizes.
* @default 'wrap'
*/
wrap?: GridWrap;
}
export type GridOwnerState = PartiallyRequired<GridBaseProps, 'size' | 'offset' | 'unstable_level'>;
export interface GridTypeMap<
AdditionalProps = {},
DefaultComponent extends React.ElementType = 'div',
> {
props: AdditionalProps & GridBaseProps & { sx?: SxProps<Theme> } & SystemProps<Theme>;
defaultComponent: DefaultComponent;
}
export type GridProps<
RootComponent extends React.ElementType = GridTypeMap['defaultComponent'],
AdditionalProps = {
component?: React.ElementType;
},
> = OverrideProps<GridTypeMap<AdditionalProps, RootComponent>, RootComponent>;

View File

@@ -0,0 +1,253 @@
'use client';
import * as React from 'react';
import PropTypes from 'prop-types';
import clsx from 'clsx';
import { OverridableComponent } from '@mui/types';
import isMuiElement from '@mui/utils/isMuiElement';
import generateUtilityClass from '@mui/utils/generateUtilityClass';
import composeClasses from '@mui/utils/composeClasses';
import systemStyled from '../styled';
import useThemePropsSystem from '../useThemeProps';
import useThemeSystem from '../useTheme';
import { extendSxProp } from '../styleFunctionSx';
import createTheme, { Breakpoint, Breakpoints } from '../createTheme';
import {
generateGridStyles,
generateGridSizeStyles,
generateGridColumnsStyles,
generateGridColumnSpacingStyles,
generateGridRowSpacingStyles,
generateGridDirectionStyles,
generateGridOffsetStyles,
generateSizeClassNames,
generateSpacingClassNames,
generateDirectionClasses,
} from './gridGenerator';
import { CreateMUIStyled } from '../createStyled';
import { GridTypeMap, GridOwnerState, GridProps, GridOffset, GridSize } from './GridProps';
import deleteLegacyGridProps from './deleteLegacyGridProps';
const defaultTheme = createTheme();
// widening Theme to any so that the consumer can own the theme structure.
const defaultCreateStyledComponent = (systemStyled as CreateMUIStyled<any>)('div', {
name: 'MuiGrid',
slot: 'Root',
});
function useThemePropsDefault<T extends {}>(props: T) {
return useThemePropsSystem({
props,
name: 'MuiGrid',
defaultTheme,
});
}
export default function createGrid(
options: {
createStyledComponent?: typeof defaultCreateStyledComponent;
useThemeProps?: typeof useThemePropsDefault;
useTheme?: typeof useThemeSystem;
componentName?: string;
} = {},
) {
const {
// This will allow adding custom styled fn (for example for custom sx style function)
createStyledComponent = defaultCreateStyledComponent,
useThemeProps = useThemePropsDefault,
useTheme = useThemeSystem,
componentName = 'MuiGrid',
} = options;
const useUtilityClasses = (ownerState: GridOwnerState, theme: typeof defaultTheme) => {
const { container, direction, spacing, wrap, size } = ownerState;
const slots = {
root: [
'root',
container && 'container',
wrap !== 'wrap' && `wrap-xs-${String(wrap)}`,
...generateDirectionClasses(direction),
...generateSizeClassNames(size),
...(container ? generateSpacingClassNames(spacing, theme.breakpoints.keys[0]) : []),
],
};
return composeClasses(slots, (slot) => generateUtilityClass(componentName, slot), {});
};
function parseResponsiveProp<T extends GridSize | GridOffset>(
propValue: T | null | (T | null)[] | { [key in Breakpoint]?: T | null },
breakpoints: Breakpoints,
shouldUseValue: (val: T) => boolean = () => true,
): { [key in Breakpoint]: T } {
const parsedProp = {} as { [key in Breakpoint]: T };
if (propValue === null) {
return parsedProp;
}
if (Array.isArray(propValue)) {
propValue.forEach((value, index) => {
if (value !== null && shouldUseValue(value) && breakpoints.keys[index]) {
parsedProp[breakpoints.keys[index]] = value;
}
});
} else if (typeof propValue === 'object') {
Object.keys(propValue).forEach((key) => {
const value = propValue[key as Breakpoint];
if (value !== null && value !== undefined && shouldUseValue(value)) {
parsedProp[key as Breakpoint] = value;
}
});
} else {
parsedProp[breakpoints.keys[0]] = propValue;
}
return parsedProp;
}
const GridRoot = createStyledComponent<{
ownerState: GridOwnerState;
}>(
generateGridColumnsStyles,
generateGridColumnSpacingStyles,
generateGridRowSpacingStyles,
generateGridSizeStyles,
generateGridDirectionStyles,
generateGridStyles,
generateGridOffsetStyles,
);
const Grid = React.forwardRef(function Grid(inProps, ref) {
const theme = useTheme();
const themeProps = useThemeProps<typeof inProps & { component?: React.ElementType }>(inProps);
const props = extendSxProp(themeProps) as Omit<typeof themeProps, 'color'> & GridOwnerState; // `color` type conflicts with html color attribute.
// TODO v8: Remove when removing the legacy Grid component
deleteLegacyGridProps(props, theme.breakpoints);
const {
className,
children,
columns: columnsProp = 12,
container = false,
component = 'div',
direction = 'row',
wrap = 'wrap',
size: sizeProp = {},
offset: offsetProp = {},
spacing: spacingProp = 0,
rowSpacing: rowSpacingProp = spacingProp,
columnSpacing: columnSpacingProp = spacingProp,
unstable_level: level = 0,
...other
} = props;
const size = parseResponsiveProp<GridSize>(sizeProp, theme.breakpoints, (val) => val !== false);
const offset = parseResponsiveProp<GridOffset>(offsetProp, theme.breakpoints);
const columns = inProps.columns ?? (level ? undefined : columnsProp);
const spacing = inProps.spacing ?? (level ? undefined : spacingProp);
const rowSpacing =
inProps.rowSpacing ?? inProps.spacing ?? (level ? undefined : rowSpacingProp);
const columnSpacing =
inProps.columnSpacing ?? inProps.spacing ?? (level ? undefined : columnSpacingProp);
const ownerState = {
...props,
level,
columns,
container,
direction,
wrap,
spacing,
rowSpacing,
columnSpacing,
size,
offset,
};
const classes = useUtilityClasses(ownerState, theme);
return (
<GridRoot
ref={ref}
as={component}
ownerState={ownerState}
className={clsx(classes.root, className)}
{...other}
>
{React.Children.map(children, (child) => {
if (
React.isValidElement<{ container?: unknown }>(child) &&
isMuiElement(child, ['Grid']) &&
container &&
child.props.container
) {
return React.cloneElement(child, {
unstable_level: (child.props as GridProps)?.unstable_level ?? level + 1,
} as GridProps);
}
return child;
})}
</GridRoot>
);
}) as OverridableComponent<GridTypeMap>;
Grid.propTypes /* remove-proptypes */ = {
children: PropTypes.node,
className: PropTypes.string,
columns: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.number),
PropTypes.number,
PropTypes.object,
]),
columnSpacing: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])),
PropTypes.number,
PropTypes.object,
PropTypes.string,
]),
component: PropTypes.elementType,
container: PropTypes.bool,
direction: PropTypes.oneOfType([
PropTypes.oneOf(['column-reverse', 'column', 'row-reverse', 'row']),
PropTypes.arrayOf(PropTypes.oneOf(['column-reverse', 'column', 'row-reverse', 'row'])),
PropTypes.object,
]),
offset: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number])),
PropTypes.object,
]),
rowSpacing: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])),
PropTypes.number,
PropTypes.object,
PropTypes.string,
]),
size: PropTypes.oneOfType([
PropTypes.string,
PropTypes.bool,
PropTypes.number,
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.bool, PropTypes.number])),
PropTypes.object,
]),
spacing: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])),
PropTypes.number,
PropTypes.object,
PropTypes.string,
]),
sx: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])),
PropTypes.func,
PropTypes.object,
]),
wrap: PropTypes.oneOf(['nowrap', 'wrap-reverse', 'wrap']),
};
// @ts-ignore internal logic for nested grid
Grid.muiName = 'Grid';
return Grid;
}

View File

@@ -0,0 +1,54 @@
import { Breakpoint, Breakpoints } from '../createTheme';
const getLegacyGridWarning = (propName: 'item' | 'zeroMinWidth' | Breakpoint) => {
if (['item', 'zeroMinWidth'].includes(propName)) {
return `The \`${propName}\` prop has been removed and is no longer necessary. You can safely remove it.`;
}
// #host-reference
return `The \`${propName}\` prop has been removed. See https://mui.com/material-ui/migration/upgrade-to-grid-v2/ for migration instructions.`;
};
const warnedAboutProps: string[] = [];
/**
* Deletes the legacy Grid component props from the `props` object and warns once about them if found.
*
* @param {object} props The props object to remove the legacy Grid props from.
* @param {Breakpoints} breakpoints The breakpoints object.
*/
export default function deleteLegacyGridProps(
props: { item?: boolean; zeroMinWidth?: boolean } & Partial<
Record<Breakpoint, 'auto' | number | boolean>
> &
Record<string, any>,
breakpoints: Breakpoints,
) {
const propsToWarn: ('item' | 'zeroMinWidth' | Breakpoint)[] = [];
if (props.item !== undefined) {
delete props.item;
propsToWarn.push('item');
}
if (props.zeroMinWidth !== undefined) {
delete props.zeroMinWidth;
propsToWarn.push('zeroMinWidth');
}
breakpoints.keys.forEach((breakpoint) => {
if (props[breakpoint] !== undefined) {
propsToWarn.push(breakpoint);
delete props[breakpoint];
}
});
if (process.env.NODE_ENV !== 'production') {
propsToWarn.forEach((prop) => {
if (!warnedAboutProps.includes(prop)) {
warnedAboutProps.push(prop);
console.warn(`MUI Grid: ${getLegacyGridWarning(prop)}\n`);
}
});
}
}

View File

@@ -0,0 +1,52 @@
import generateUtilityClasses from '@mui/utils/generateUtilityClasses';
import generateUtilityClass from '@mui/utils/generateUtilityClass';
export interface GridClasses {
/** Styles applied to the root element. */
root: string;
/** Styles applied to the root element if `container={true}`. */
container: string;
/** Styles applied to the root element if `direction="column"`. */
'direction-xs-column': string;
/** Styles applied to the root element if `direction="column-reverse"`. */
'direction-xs-column-reverse': string;
/** Styles applied to the root element if `direction="row-reverse"`. */
'direction-xs-row-reverse': string;
/** Styles applied to the root element if `wrap="nowrap"`. */
'wrap-xs-nowrap': string;
/** Styles applied to the root element if `wrap="reverse"`. */
'wrap-xs-wrap-reverse': string;
}
export type GridClassKey = keyof GridClasses;
export function getGridUtilityClass(slot: string): string {
return generateUtilityClass('MuiGrid', slot);
}
const SPACINGS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as const;
const DIRECTIONS = ['column-reverse', 'column', 'row-reverse', 'row'] as const;
const WRAPS = ['nowrap', 'wrap-reverse', 'wrap'] as const;
const GRID_SIZES = ['auto', 'grow', 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] as const;
const gridClasses: GridClasses = generateUtilityClasses('MuiGrid', [
'root',
'container',
'item',
// spacings
...SPACINGS.map((spacing) => `spacing-xs-${spacing}` as const),
// direction values
...DIRECTIONS.map((direction) => `direction-xs-${direction}` as const),
// wrap values
...WRAPS.map((wrap) => `wrap-xs-${wrap}` as const),
// grid sizes for all breakpoints
...GRID_SIZES.map((size) => `grid-xs-${size}` as const),
...GRID_SIZES.map((size) => `grid-sm-${size}` as const),
...GRID_SIZES.map((size) => `grid-md-${size}` as const),
...GRID_SIZES.map((size) => `grid-lg-${size}` as const),
...GRID_SIZES.map((size) => `grid-xl-${size}` as const),
]);
export default gridClasses;

View File

@@ -0,0 +1,411 @@
import { expect } from 'chai';
import sinon from 'sinon';
import createSpacing from '../createTheme/createSpacing';
import createBreakpoints from '../createBreakpoints/createBreakpoints';
import {
generateGridStyles,
generateGridSizeStyles,
generateGridColumnsStyles,
generateGridRowSpacingStyles,
generateGridColumnSpacingStyles,
generateGridOffsetStyles,
generateSizeClassNames,
generateSpacingClassNames,
generateDirectionClasses,
} from './gridGenerator';
const spacing = createSpacing();
const breakpoints = createBreakpoints({});
describe('grid generator', () => {
describe('generateGridStyles', () => {
it('root container', () => {
const result = generateGridStyles({ ownerState: { container: true, unstable_level: 0 } });
expect(result).to.deep.equal({
minWidth: 0,
boxSizing: 'border-box',
display: 'flex',
flexWrap: 'wrap',
gap: 'var(--Grid-rowSpacing) var(--Grid-columnSpacing)',
});
});
it('nested container level 1', () => {
const result = generateGridStyles({ ownerState: { container: true, unstable_level: 1 } });
sinon.assert.match(result, {
gap: `var(--Grid-rowSpacing) var(--Grid-columnSpacing)`,
});
});
it('nested container level 2', () => {
const result = generateGridStyles({ ownerState: { container: true, unstable_level: 2 } });
sinon.assert.match(result, {
gap: `var(--Grid-rowSpacing) var(--Grid-columnSpacing)`,
});
});
it('item', () => {
const result = generateGridStyles({ ownerState: { container: false, unstable_level: 1 } });
expect(result).to.deep.equal({
minWidth: 0,
boxSizing: 'border-box',
});
});
});
describe('generateGridSizeStyles', () => {
it('works with supported format', () => {
expect(
generateGridSizeStyles({
theme: { breakpoints },
ownerState: {
size: {
xs: 'auto',
sm: 6,
md: 'grow',
lg: 4,
xl: 'auto',
},
// should not consider other props
rowSpacing: 1,
columnSpacing: { xs: 1, sm: 2 },
},
}),
).to.deep.equal({
flexBasis: 'auto',
flexGrow: 0,
flexShrink: 0,
maxWidth: 'none',
width: 'auto',
'@media (min-width:600px)': {
flexBasis: 'auto',
flexGrow: 0,
width: `calc(100% * 6 / var(--Grid-parent-columns) - (var(--Grid-parent-columns) - 6) * (var(--Grid-parent-columnSpacing) / var(--Grid-parent-columns)))`,
},
'@media (min-width:900px)': {
flexBasis: 0,
flexGrow: 1,
maxWidth: '100%',
},
'@media (min-width:1200px)': {
flexBasis: 'auto',
flexGrow: 0,
width: `calc(100% * 4 / var(--Grid-parent-columns) - (var(--Grid-parent-columns) - 4) * (var(--Grid-parent-columnSpacing) / var(--Grid-parent-columns)))`,
},
'@media (min-width:1536px)': {
flexBasis: 'auto',
flexGrow: 0,
flexShrink: 0,
maxWidth: 'none',
width: 'auto',
},
});
});
});
describe('generateGridColumnsStyles', () => {
it('supports number', () => {
expect(
generateGridColumnsStyles({
theme: { breakpoints },
ownerState: { container: true, columns: 16 },
}),
).to.deep.equal({
'--Grid-columns': 16,
'> *': {
'--Grid-parent-columns': 16,
},
});
});
it('supports responsive', () => {
expect(
generateGridColumnsStyles({
theme: { breakpoints },
ownerState: { container: true, columns: { xs: 6, sm: 8, md: 12, lg: 16 } },
}),
).to.deep.equal({
'--Grid-columns': 6,
'> *': {
'--Grid-parent-columns': 6,
},
'@media (min-width:600px)': {
'--Grid-columns': 8,
'> *': {
'--Grid-parent-columns': 8,
},
},
'@media (min-width:900px)': {
'--Grid-columns': 12,
'> *': {
'--Grid-parent-columns': 12,
},
},
'@media (min-width:1200px)': {
'--Grid-columns': 16,
'> *': {
'--Grid-parent-columns': 16,
},
},
});
});
it('has default of 12 if the smallest breakpoint is not specified', () => {
expect(
generateGridColumnsStyles({
theme: { breakpoints },
ownerState: { container: true, columns: { lg: 16 } },
}),
).to.deep.equal({
'--Grid-columns': 12,
'@media (min-width:1200px)': {
'--Grid-columns': 16,
'> *': {
'--Grid-parent-columns': 16,
},
},
});
});
});
describe('generateGridRowSpacingStyles', () => {
it('supports number', () => {
expect(
generateGridRowSpacingStyles({
theme: { breakpoints, spacing },
ownerState: { container: true, rowSpacing: 2 },
}),
).to.deep.equal({
'--Grid-rowSpacing': '16px',
'> *': {
'--Grid-parent-rowSpacing': '16px',
},
});
});
it('supports string', () => {
expect(
generateGridRowSpacingStyles({
theme: { breakpoints, spacing },
ownerState: { container: true, rowSpacing: '1rem' },
}),
).to.deep.equal({
'--Grid-rowSpacing': '1rem',
'> *': {
'--Grid-parent-rowSpacing': '1rem',
},
});
});
it('supports responsive', () => {
expect(
generateGridRowSpacingStyles({
theme: { breakpoints, spacing },
ownerState: { container: true, rowSpacing: { xs: 2, md: 3, xl: 0 } },
}),
).to.deep.equal({
'--Grid-rowSpacing': '16px',
'> *': {
'--Grid-parent-rowSpacing': '16px',
},
'@media (min-width:900px)': {
'--Grid-rowSpacing': '24px',
'> *': {
'--Grid-parent-rowSpacing': '24px',
},
},
'@media (min-width:1536px)': {
'--Grid-rowSpacing': '0px',
'> *': {
'--Grid-parent-rowSpacing': '0px',
},
},
});
expect(
generateGridRowSpacingStyles({
theme: { breakpoints, spacing },
ownerState: { container: true, rowSpacing: { xs: 0, md: 2, xl: 0 } },
}),
).to.deep.equal({
'--Grid-rowSpacing': '0px',
'> *': {
'--Grid-parent-rowSpacing': '0px',
},
'@media (min-width:900px)': {
'--Grid-rowSpacing': '16px',
'> *': {
'--Grid-parent-rowSpacing': '16px',
},
},
'@media (min-width:1536px)': {
'--Grid-rowSpacing': '0px',
'> *': {
'--Grid-parent-rowSpacing': '0px',
},
},
});
});
});
describe('generateGridColumnSpacingStyles', () => {
it('supports number', () => {
expect(
generateGridColumnSpacingStyles({
theme: { breakpoints, spacing },
ownerState: { container: true, columnSpacing: 2 },
}),
).to.deep.equal({
'--Grid-columnSpacing': '16px',
'> *': {
'--Grid-parent-columnSpacing': '16px',
},
});
});
it('supports string', () => {
expect(
generateGridColumnSpacingStyles({
theme: { breakpoints, spacing },
ownerState: { container: true, columnSpacing: '1rem' },
}),
).to.deep.equal({
'--Grid-columnSpacing': '1rem',
'> *': {
'--Grid-parent-columnSpacing': '1rem',
},
});
});
it('supports responsive', () => {
expect(
generateGridColumnSpacingStyles({
theme: { breakpoints, spacing },
ownerState: { container: true, columnSpacing: { xs: 2, md: 3, xl: 0 } },
}),
).to.deep.equal({
'--Grid-columnSpacing': '16px',
'> *': {
'--Grid-parent-columnSpacing': '16px',
},
'@media (min-width:900px)': {
'--Grid-columnSpacing': '24px',
'> *': {
'--Grid-parent-columnSpacing': '24px',
},
},
'@media (min-width:1536px)': {
'--Grid-columnSpacing': '0px',
'> *': {
'--Grid-parent-columnSpacing': '0px',
},
},
});
expect(
generateGridColumnSpacingStyles({
theme: { breakpoints, spacing },
ownerState: { container: true, columnSpacing: { xs: 0, md: 2, xl: 0 } },
}),
).to.deep.equal({
'--Grid-columnSpacing': '0px',
'> *': {
'--Grid-parent-columnSpacing': '0px',
},
'@media (min-width:900px)': {
'--Grid-columnSpacing': '16px',
'> *': {
'--Grid-parent-columnSpacing': '16px',
},
},
'@media (min-width:1536px)': {
'--Grid-columnSpacing': '0px',
'> *': {
'--Grid-parent-columnSpacing': '0px',
},
},
});
});
});
describe('generateGridOffsetStyles', () => {
it('supports responsive object', () => {
expect(
generateGridOffsetStyles({
theme: { breakpoints, spacing },
ownerState: { offset: { xs: 0, md: 5, lg: 'auto' } },
}),
).to.deep.equal({
marginLeft: '0px',
'@media (min-width:900px)': {
marginLeft: `calc(100% * 5 / var(--Grid-parent-columns) + var(--Grid-parent-columnSpacing) * 5 / var(--Grid-parent-columns))`,
},
'@media (min-width:1200px)': {
marginLeft: `auto`,
},
});
});
});
describe('class names', () => {
it('should generate correct grid size class names', () => {
expect(
generateSizeClassNames({
xs: 'auto',
sm: 4,
md: false,
lg: undefined,
xl: true,
}),
).to.deep.equal(['grid-xs-auto', 'grid-sm-4', 'grid-xl-true']);
});
it('should generate correct spacing class names', () => {
expect(generateSpacingClassNames()).to.deep.equal([]);
expect(generateSpacingClassNames([0, 1])).to.deep.equal([]);
expect(generateSpacingClassNames(2)).to.deep.equal(['spacing-xs-2']);
expect(
generateSpacingClassNames({
xs: 0,
sm: 2,
lg: 4,
xl: '1rem', // should not appear in class name
}),
).to.deep.equal(['spacing-sm-2', 'spacing-lg-4']);
});
it('should work with any breakpoint', () => {
expect(
generateSizeClassNames({
mobile: 'auto',
tablet: 4,
}),
).to.deep.equal(['grid-mobile-auto', 'grid-tablet-4']);
expect(generateSpacingClassNames(2, 'mobile')).to.deep.equal(['spacing-mobile-2']);
expect(
generateSpacingClassNames({
mobile: 3,
tablet: 4,
}),
).to.deep.equal(['spacing-mobile-3', 'spacing-tablet-4']);
});
});
describe('generateDirectionClasses', () => {
it('should generate correct direction class names', () => {
expect(generateDirectionClasses()).to.deep.equal([]);
expect(generateDirectionClasses('row')).to.deep.equal(['direction-xs-row']);
expect(generateDirectionClasses('column')).to.deep.equal(['direction-xs-column']);
expect(
generateDirectionClasses({
xs: 'row',
sm: 'column',
md: 'row',
}),
).to.deep.equal(['direction-xs-row', 'direction-sm-column', 'direction-md-row']);
});
});
});

View File

@@ -0,0 +1,227 @@
import { Breakpoints } from '../createBreakpoints/createBreakpoints';
import { Spacing } from '../createTheme/createSpacing';
import { ResponsiveStyleValue } from '../styleFunctionSx';
import { GridDirection, GridOwnerState } from './GridProps';
import { traverseBreakpoints } from './traverseBreakpoints';
interface Props {
theme: { breakpoints: Breakpoints; spacing?: Spacing };
ownerState: GridOwnerState;
}
function getSelfSpacingVar(axis: 'row' | 'column') {
return `--Grid-${axis}Spacing`;
}
function getParentSpacingVar(axis: 'row' | 'column') {
return `--Grid-parent-${axis}Spacing`;
}
const selfColumnsVar = '--Grid-columns';
const parentColumnsVar = '--Grid-parent-columns';
export const generateGridSizeStyles = ({ theme, ownerState }: Props) => {
const styles = {};
traverseBreakpoints<'auto' | 'grow' | number | false>(
theme.breakpoints,
ownerState.size,
(appendStyle, value) => {
let style = {};
if (value === 'grow') {
style = {
flexBasis: 0,
flexGrow: 1,
maxWidth: '100%',
};
}
if (value === 'auto') {
style = {
flexBasis: 'auto',
flexGrow: 0,
flexShrink: 0,
maxWidth: 'none',
width: 'auto',
};
}
if (typeof value === 'number') {
style = {
flexGrow: 0,
flexBasis: 'auto',
width: `calc(100% * ${value} / var(${parentColumnsVar}) - (var(${parentColumnsVar}) - ${value}) * (var(${getParentSpacingVar('column')}) / var(${parentColumnsVar})))`,
};
}
appendStyle(styles, style);
},
);
return styles;
};
export const generateGridOffsetStyles = ({ theme, ownerState }: Props) => {
const styles = {};
traverseBreakpoints<number | 'auto'>(
theme.breakpoints,
ownerState.offset,
(appendStyle, value) => {
let style = {};
if (value === 'auto') {
style = {
marginLeft: 'auto',
};
}
if (typeof value === 'number') {
style = {
marginLeft:
value === 0
? '0px'
: `calc(100% * ${value} / var(${parentColumnsVar}) + var(${getParentSpacingVar('column')}) * ${value} / var(${parentColumnsVar}))`,
};
}
appendStyle(styles, style);
},
);
return styles;
};
export const generateGridColumnsStyles = ({ theme, ownerState }: Props) => {
if (!ownerState.container) {
return {};
}
const styles = {
[selfColumnsVar]: 12,
};
traverseBreakpoints<number>(theme.breakpoints, ownerState.columns, (appendStyle, value) => {
const columns = value ?? 12;
appendStyle(styles, {
[selfColumnsVar]: columns,
'> *': {
[parentColumnsVar]: columns,
},
});
});
return styles;
};
export const generateGridRowSpacingStyles = ({ theme, ownerState }: Props) => {
if (!ownerState.container) {
return {};
}
const styles = {};
traverseBreakpoints<number | string>(
theme.breakpoints,
ownerState.rowSpacing,
(appendStyle, value) => {
const spacing = typeof value === 'string' ? value : theme.spacing?.(value);
appendStyle(styles, {
[getSelfSpacingVar('row')]: spacing,
'> *': {
[getParentSpacingVar('row')]: spacing,
},
});
},
);
return styles;
};
export const generateGridColumnSpacingStyles = ({ theme, ownerState }: Props) => {
if (!ownerState.container) {
return {};
}
const styles = {};
traverseBreakpoints<number | string>(
theme.breakpoints,
ownerState.columnSpacing,
(appendStyle, value) => {
const spacing = typeof value === 'string' ? value : theme.spacing?.(value);
appendStyle(styles, {
[getSelfSpacingVar('column')]: spacing,
'> *': {
[getParentSpacingVar('column')]: spacing,
},
});
},
);
return styles;
};
export const generateGridDirectionStyles = ({ theme, ownerState }: Props) => {
if (!ownerState.container) {
return {};
}
const styles = {};
traverseBreakpoints<number | string>(
theme.breakpoints,
ownerState.direction,
(appendStyle, value) => {
appendStyle(styles, { flexDirection: value });
},
);
return styles;
};
export const generateGridStyles = ({ ownerState }: Props): {} => {
return {
minWidth: 0,
boxSizing: 'border-box',
...(ownerState.container && {
display: 'flex',
flexWrap: 'wrap',
...(ownerState.wrap &&
ownerState.wrap !== 'wrap' && {
flexWrap: ownerState.wrap,
}),
gap: `var(${getSelfSpacingVar('row')}) var(${getSelfSpacingVar('column')})`,
}),
};
};
export const generateSizeClassNames = (size: GridOwnerState['size']) => {
const classNames: string[] = [];
Object.entries(size).forEach(([key, value]) => {
if (value !== false && value !== undefined) {
classNames.push(`grid-${key}-${String(value)}`);
}
});
return classNames;
};
export const generateSpacingClassNames = (
spacing: GridOwnerState['spacing'],
smallestBreakpoint: string = 'xs',
) => {
function isValidSpacing(val: GridOwnerState['spacing'] | null) {
if (val === undefined) {
return false;
}
return (
(typeof val === 'string' && !Number.isNaN(Number(val))) ||
(typeof val === 'number' && val > 0)
);
}
if (isValidSpacing(spacing)) {
return [`spacing-${smallestBreakpoint}-${String(spacing)}`];
}
if (typeof spacing === 'object' && !Array.isArray(spacing)) {
const classNames: string[] = [];
Object.entries(spacing).forEach(([key, value]) => {
if (isValidSpacing(value)) {
classNames.push(`spacing-${key}-${String(value)}`);
}
});
return classNames;
}
return [];
};
export const generateDirectionClasses = (
direction: ResponsiveStyleValue<GridDirection> | undefined,
): string[] => {
if (direction === undefined) {
return [];
}
if (typeof direction === 'object') {
return Object.entries(direction).map(([key, value]) => `direction-${key}-${value}`);
}
return [`direction-xs-${String(direction)}`];
};

View File

@@ -0,0 +1,11 @@
export { default } from './Grid';
export { default as createGrid } from './createGrid';
export * from './GridProps';
export { default as gridClasses } from './gridClasses';
export * from './gridClasses';
export { traverseBreakpoints as unstable_traverseBreakpoints } from './traverseBreakpoints';
export {
generateDirectionClasses as unstable_generateDirectionClasses,
generateSizeClassNames as unstable_generateSizeClassNames,
generateSpacingClassNames as unstable_generateSpacingClassNames,
} from './gridGenerator';

View File

@@ -0,0 +1,225 @@
import { expect } from 'chai';
import createBreakpoints from '../createBreakpoints/createBreakpoints';
import { traverseBreakpoints, filterBreakpointKeys } from './traverseBreakpoints';
const breakpoints = createBreakpoints({});
describe('traverse breakpoints', () => {
it('supports array', () => {
const styles = {};
traverseBreakpoints(breakpoints, [1, 2, 3, 4, 5], (appendStyle, value) => {
appendStyle(styles, { margin: value });
});
expect(styles).to.deep.equal({
margin: 1,
'@media (min-width:600px)': {
margin: 2,
},
'@media (min-width:900px)': {
margin: 3,
},
'@media (min-width:1200px)': {
margin: 4,
},
'@media (min-width:1536px)': {
margin: 5,
},
});
});
it('supports object', () => {
const styles = {};
traverseBreakpoints(
breakpoints,
{ xs: 1, sm: 2, md: 3, lg: 4, xl: 5 },
(appendStyle, value) => {
appendStyle(styles, { margin: value });
},
);
expect(styles).to.deep.equal({
margin: 1,
'@media (min-width:600px)': {
margin: 2,
},
'@media (min-width:900px)': {
margin: 3,
},
'@media (min-width:1200px)': {
margin: 4,
},
'@media (min-width:1536px)': {
margin: 5,
},
});
});
it('works with mixed object', () => {
const styles = {};
traverseBreakpoints(
breakpoints,
{ a: 2, b: 5, xs: 1, sm: 2, md: 3, foo: () => {}, bar: [] },
(appendStyle, value) => {
appendStyle(styles, { margin: value });
},
);
expect(styles).to.deep.equal({
margin: 1,
'@media (min-width:600px)': {
margin: 2,
},
'@media (min-width:900px)': {
margin: 3,
},
});
});
it('does not iterate undefined value', () => {
const styles = {};
traverseBreakpoints(breakpoints, { xs: 1, sm: undefined, md: 3 }, (appendStyle, value) => {
appendStyle(styles, { margin: value });
});
expect(styles).to.deep.equal({
margin: 1,
'@media (min-width:900px)': {
margin: 3,
},
});
});
it('filters out breakpoints keys based on responsive keys', () => {
const styles = { sm: 6, md: 3, xl: 2, xs: 1 };
const filteredKeys = filterBreakpointKeys(breakpoints.keys, Object.keys(styles));
expect(filteredKeys).to.deep.equal(['xs', 'sm', 'md', 'xl']);
});
describe('custom breakpoints', () => {
const customBreakpoints = createBreakpoints({
// @ts-ignore
values: { xxs: 0, xs: 400, sm: 600, md: 768 },
});
it('supports array', () => {
const styles = {};
traverseBreakpoints(customBreakpoints, [1, 2, 3, 4, 5], (appendStyle, value) => {
appendStyle(styles, { margin: value });
});
expect(styles).to.deep.equal({
margin: 1,
'@media (min-width:400px)': {
margin: 2,
},
'@media (min-width:600px)': {
margin: 3,
},
'@media (min-width:768px)': {
margin: 4,
},
});
});
it('supports object', () => {
const styles = {};
traverseBreakpoints(
customBreakpoints,
{ xxs: 1, xs: 2, sm: 3, md: 4, lg: 5 }, // lg is not a part of custom breakpoints
(appendStyle, value) => {
appendStyle(styles, { margin: value });
},
);
expect(styles).to.deep.equal({
margin: 1,
'@media (min-width:400px)': {
margin: 2,
},
'@media (min-width:600px)': {
margin: 3,
},
'@media (min-width:768px)': {
margin: 4,
},
});
});
it('supports object (random order)', () => {
const newBreakpoints = createBreakpoints({
// @ts-ignore
values: { mobile: 0, laptop: 1024, tablet: 640, desktop: 1280 },
});
const styles = {};
traverseBreakpoints(
newBreakpoints,
{ monitor: 5, laptop: 3, mobile: 4, desktop: 2, tablet: 1 }, // lg is not a part of custom breakpoints
(appendStyle, value) => {
appendStyle(styles, { margin: value });
},
);
expect(styles).to.deep.equal({
margin: 4,
'@media (min-width:640px)': {
margin: 1,
},
'@media (min-width:1024px)': {
margin: 3,
},
'@media (min-width:1280px)': {
margin: 2,
},
});
});
});
describe('new breakpoints', () => {
const newBreakpoints = createBreakpoints({
values: {
// order does not matter
// @ts-ignore
laptop: 1024,
tablet: 640,
mobile: 0,
desktop: 1280,
},
});
it('supports array', () => {
const styles = {};
traverseBreakpoints(newBreakpoints, [1, 2, 3, 4], (appendStyle, value) => {
appendStyle(styles, { margin: value });
});
expect(styles).to.deep.equal({
margin: 1,
'@media (min-width:640px)': {
margin: 2,
},
'@media (min-width:1024px)': {
margin: 3,
},
'@media (min-width:1280px)': {
margin: 4,
},
});
});
it('supports object', () => {
const styles = {};
traverseBreakpoints(
newBreakpoints,
{ mobile: 1, tablet: 2, laptop: 3, desktop: 4, monitor: 5 }, // monitor is not a part of custom breakpoints
(appendStyle, value) => {
appendStyle(styles, { margin: value });
},
);
expect(styles).to.deep.equal({
margin: 1,
'@media (min-width:640px)': {
margin: 2,
},
'@media (min-width:1024px)': {
margin: 3,
},
'@media (min-width:1280px)': {
margin: 4,
},
});
});
});
});

View File

@@ -0,0 +1,58 @@
import { Breakpoints, Breakpoint } from '../createBreakpoints/createBreakpoints';
export const filterBreakpointKeys = (breakpointsKeys: Breakpoint[], responsiveKeys: string[]) =>
breakpointsKeys.filter((key: string) => responsiveKeys.includes(key));
interface Iterator<T> {
(appendStyle: (responsiveStyles: Record<string, any>, style: object) => void, value: T): void;
}
export const traverseBreakpoints = <T = unknown>(
breakpoints: Breakpoints,
responsive: T | T[] | Record<string, any> | undefined,
iterator: Iterator<T>,
) => {
const smallestBreakpoint = breakpoints.keys[0]; // the keys is sorted from smallest to largest by `createBreakpoints`.
if (Array.isArray(responsive)) {
responsive.forEach((breakpointValue, index) => {
iterator((responsiveStyles, style) => {
if (index <= breakpoints.keys.length - 1) {
if (index === 0) {
Object.assign(responsiveStyles, style);
} else {
responsiveStyles[breakpoints.up(breakpoints.keys[index])] = style;
}
}
}, breakpointValue as T);
});
} else if (responsive && typeof responsive === 'object') {
// prevent null
// responsive could be a very big object, pick the smallest responsive values
const keys =
Object.keys(responsive).length > breakpoints.keys.length
? breakpoints.keys
: filterBreakpointKeys(breakpoints.keys, Object.keys(responsive));
keys.forEach((key) => {
if (breakpoints.keys.includes(key as Breakpoint)) {
// @ts-ignore already checked that responsive is an object
const breakpointValue: T = responsive[key];
if (breakpointValue !== undefined) {
iterator((responsiveStyles, style) => {
if (smallestBreakpoint === key) {
Object.assign(responsiveStyles, style);
} else {
responsiveStyles[breakpoints.up(key as Breakpoint)] = style;
}
}, breakpointValue);
}
}
});
} else if (typeof responsive === 'number' || typeof responsive === 'string') {
iterator((responsiveStyles, style) => {
Object.assign(responsiveStyles, style);
}, responsive);
}
};

View File

@@ -0,0 +1,182 @@
/* eslint-disable no-eval */
import { expect } from 'chai';
import { createRenderer } from '@mui/internal-test-utils';
import InitColorSchemeScript from '@mui/system/InitColorSchemeScript';
import {
DEFAULT_ATTRIBUTE,
DEFAULT_MODE_STORAGE_KEY,
DEFAULT_COLOR_SCHEME_STORAGE_KEY,
} from './InitColorSchemeScript';
describe('InitColorSchemeScript', () => {
const { render } = createRenderer();
let originalMatchmedia;
let storage = {};
const createMatchMedia = (matches) => () => ({
matches,
addEventListener: () => {},
removeEventListener: () => {},
});
beforeEach(() => {
// Create mocks of localStorage getItem and setItem functions
Object.defineProperty(globalThis, 'localStorage', {
value: {
getItem: (key) => storage[key],
},
configurable: true,
});
// clear the localstorage
storage = {};
document.documentElement.removeAttribute(DEFAULT_ATTRIBUTE);
window.matchMedia = createMatchMedia(false);
});
afterEach(() => {
window.matchMedia = originalMatchmedia;
});
it('should set `light` color scheme to body', () => {
storage[DEFAULT_MODE_STORAGE_KEY] = 'light';
storage[`${DEFAULT_COLOR_SCHEME_STORAGE_KEY}-light`] = 'foo';
const { container } = render(<InitColorSchemeScript />);
eval(container.firstChild.textContent);
expect(document.documentElement.getAttribute(DEFAULT_ATTRIBUTE)).to.equal('foo');
});
it('should set `light` color scheme with class', () => {
storage[DEFAULT_MODE_STORAGE_KEY] = 'light';
storage[`${DEFAULT_COLOR_SCHEME_STORAGE_KEY}-light`] = 'foo';
document.documentElement.classList.remove(...document.documentElement.classList);
const { container } = render(<InitColorSchemeScript attribute="class" />);
expect(container.firstChild.textContent.replace(/\s/g, '')).not.to.include(
"setAttribute('.%s',colorScheme)",
);
eval(container.firstChild.textContent);
expect(document.documentElement.classList.value).to.equal('foo');
document.documentElement.classList.remove('foo'); // cleanup
});
it('should set `light` color scheme with data', () => {
storage[DEFAULT_MODE_STORAGE_KEY] = 'light';
storage[`${DEFAULT_COLOR_SCHEME_STORAGE_KEY}-light`] = 'foo';
const { container } = render(<InitColorSchemeScript attribute="data" />);
eval(container.firstChild.textContent);
expect(document.documentElement.getAttribute('data-foo')).to.equal('');
});
it('should set custom color scheme to body with custom attribute', () => {
storage['mui-foo-mode'] = 'light';
storage[`mui-bar-color-scheme-light`] = 'flash';
const { container } = render(
<InitColorSchemeScript
modeStorageKey="mui-foo-mode"
colorSchemeStorageKey="mui-bar-color-scheme"
attribute="data-mui-baz-scheme"
/>,
);
eval(container.firstChild.textContent);
expect(document.documentElement.getAttribute('data-mui-baz-scheme')).to.equal('flash');
});
it('should switch between light and dark with class attribute', () => {
storage[DEFAULT_MODE_STORAGE_KEY] = 'light';
storage[`${DEFAULT_COLOR_SCHEME_STORAGE_KEY}-light`] = 'foo';
storage[`${DEFAULT_COLOR_SCHEME_STORAGE_KEY}-dark`] = 'bar';
const { container, rerender } = render(<InitColorSchemeScript attribute=".mode-%s" />);
eval(container.firstChild.textContent);
expect(document.documentElement.classList.value).to.equal('mode-foo');
storage[DEFAULT_MODE_STORAGE_KEY] = 'dark';
rerender(<InitColorSchemeScript attribute=".mode-%s" />);
eval(container.firstChild.textContent);
expect(document.documentElement.classList.value).to.equal('mode-bar');
document.documentElement.classList.remove('mode-bar'); // cleanup
});
it('should switch between light and dark with data-%s attribute', () => {
storage[DEFAULT_MODE_STORAGE_KEY] = 'light';
storage[`${DEFAULT_COLOR_SCHEME_STORAGE_KEY}-light`] = 'foo';
storage[`${DEFAULT_COLOR_SCHEME_STORAGE_KEY}-dark`] = 'bar';
const { container, rerender } = render(<InitColorSchemeScript attribute="[data-mode-%s]" />);
eval(container.firstChild.textContent);
expect(document.documentElement.getAttribute('data-mode-foo')).to.equal('');
expect(document.documentElement.getAttribute('data-mode-bar')).to.equal(null);
storage[DEFAULT_MODE_STORAGE_KEY] = 'dark';
rerender(<InitColorSchemeScript attribute="[data-mode-%s]" />);
eval(container.firstChild.textContent);
expect(document.documentElement.getAttribute('data-mode-bar')).to.equal('');
expect(document.documentElement.getAttribute('data-mode-foo')).to.equal(null);
});
it('should switch between light and dark with data="%s" attribute', () => {
storage[DEFAULT_MODE_STORAGE_KEY] = 'light';
storage[`${DEFAULT_COLOR_SCHEME_STORAGE_KEY}-light`] = 'foo';
storage[`${DEFAULT_COLOR_SCHEME_STORAGE_KEY}-dark`] = 'bar';
const { container, rerender } = render(<InitColorSchemeScript attribute="[data-mode='%s']" />);
eval(container.firstChild.textContent);
expect(document.documentElement.getAttribute('data-mode')).to.equal('foo');
storage[DEFAULT_MODE_STORAGE_KEY] = 'dark';
rerender(<InitColorSchemeScript attribute="[data-mode='%s']" />);
eval(container.firstChild.textContent);
expect(document.documentElement.getAttribute('data-mode')).to.equal('bar');
});
it('should set `dark` color scheme to body', () => {
storage[DEFAULT_MODE_STORAGE_KEY] = 'dark';
storage[`${DEFAULT_COLOR_SCHEME_STORAGE_KEY}-dark`] = 'bar';
const { container } = render(<InitColorSchemeScript />);
eval(container.firstChild.textContent);
expect(document.documentElement.getAttribute(DEFAULT_ATTRIBUTE)).to.equal('bar');
});
it('should set dark color scheme to body, given prefers-color-scheme is `dark`', () => {
storage[DEFAULT_MODE_STORAGE_KEY] = 'system';
storage[`${DEFAULT_COLOR_SCHEME_STORAGE_KEY}-dark`] = 'dim';
window.matchMedia = createMatchMedia(true);
const { container } = render(<InitColorSchemeScript />);
eval(container.firstChild.textContent);
expect(document.documentElement.getAttribute(DEFAULT_ATTRIBUTE)).to.equal('dim');
});
it('should set light color scheme to body, given prefers-color-scheme is NOT `dark`', () => {
storage[DEFAULT_MODE_STORAGE_KEY] = 'system';
storage[`${DEFAULT_COLOR_SCHEME_STORAGE_KEY}-light`] = 'bright';
window.matchMedia = createMatchMedia(false);
const { container } = render(<InitColorSchemeScript />);
eval(container.firstChild.textContent);
expect(document.documentElement.getAttribute(DEFAULT_ATTRIBUTE)).to.equal('bright');
});
describe('system preference', () => {
it('should set dark color scheme to body, given prefers-color-scheme is `dark`', () => {
window.matchMedia = createMatchMedia(true);
const { container } = render(<InitColorSchemeScript defaultDarkColorScheme="trueDark" />);
eval(container.firstChild.textContent);
expect(document.documentElement.getAttribute(DEFAULT_ATTRIBUTE)).to.equal('trueDark');
});
it('should set light color scheme to body, given prefers-color-scheme is NOT `dark`', () => {
window.matchMedia = createMatchMedia(false);
const { container } = render(<InitColorSchemeScript defaultLightColorScheme="yellow" />);
eval(container.firstChild.textContent);
expect(document.documentElement.getAttribute(DEFAULT_ATTRIBUTE)).to.equal('yellow');
});
});
});

View File

@@ -0,0 +1,121 @@
export const DEFAULT_MODE_STORAGE_KEY = 'mode';
export const DEFAULT_COLOR_SCHEME_STORAGE_KEY = 'color-scheme';
export const DEFAULT_ATTRIBUTE = 'data-color-scheme';
export interface InitColorSchemeScriptProps {
/**
* The default mode when the storage is empty (user's first visit).
* @default 'system'
*/
defaultMode?: 'system' | 'light' | 'dark';
/**
* The default color scheme to be used on the light mode.
* @default 'light'
*/
defaultLightColorScheme?: string;
/**
* The default color scheme to be used on the dark mode.
* * @default 'dark'
*/
defaultDarkColorScheme?: string;
/**
* The node (provided as string) used to attach the color-scheme attribute.
* @default 'document.documentElement'
*/
colorSchemeNode?: string;
/**
* localStorage key used to store `mode`.
* @default 'mode'
*/
modeStorageKey?: string;
/**
* localStorage key used to store `colorScheme`.
* @default 'color-scheme'
*/
colorSchemeStorageKey?: string;
/**
* DOM attribute for applying color scheme.
* @default 'data-color-scheme'
* @example '.mode-%s' // for class based color scheme
* @example '[data-mode-%s]' // for data-attribute without '='
*/
attribute?: 'class' | 'data' | string;
/**
* Nonce string to pass to the inline script for CSP headers.
*/
nonce?: string | undefined;
}
export default function InitColorSchemeScript(options?: InitColorSchemeScriptProps) {
const {
defaultMode = 'system',
defaultLightColorScheme = 'light',
defaultDarkColorScheme = 'dark',
modeStorageKey = DEFAULT_MODE_STORAGE_KEY,
colorSchemeStorageKey = DEFAULT_COLOR_SCHEME_STORAGE_KEY,
attribute: initialAttribute = DEFAULT_ATTRIBUTE,
colorSchemeNode = 'document.documentElement',
nonce,
} = options || {};
let setter = '';
let attribute = initialAttribute;
if (initialAttribute === 'class') {
attribute = '.%s';
}
if (initialAttribute === 'data') {
attribute = '[data-%s]';
}
if (attribute.startsWith('.')) {
const selector = attribute.substring(1);
setter += `${colorSchemeNode}.classList.remove('${selector}'.replace('%s', light), '${selector}'.replace('%s', dark));
${colorSchemeNode}.classList.add('${selector}'.replace('%s', colorScheme));`;
}
const matches = attribute.match(/\[([^[\]]+)\]/); // case [data-color-scheme='%s'] or [data-color-scheme]
if (matches) {
const [attr, value] = matches[1].split('=');
if (!value) {
setter += `${colorSchemeNode}.removeAttribute('${attr}'.replace('%s', light));
${colorSchemeNode}.removeAttribute('${attr}'.replace('%s', dark));`;
}
setter += `
${colorSchemeNode}.setAttribute('${attr}'.replace('%s', colorScheme), ${value ? `${value}.replace('%s', colorScheme)` : '""'});`;
} else if (attribute !== '.%s') {
setter += `${colorSchemeNode}.setAttribute('${attribute}', colorScheme);`;
}
return (
<script
key="mui-color-scheme-init"
suppressHydrationWarning
nonce={typeof window === 'undefined' ? nonce : ''}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: `(function() {
try {
let colorScheme = '';
const mode = localStorage.getItem('${modeStorageKey}') || '${defaultMode}';
const dark = localStorage.getItem('${colorSchemeStorageKey}-dark') || '${defaultDarkColorScheme}';
const light = localStorage.getItem('${colorSchemeStorageKey}-light') || '${defaultLightColorScheme}';
if (mode === 'system') {
// handle system mode
const mql = window.matchMedia('(prefers-color-scheme: dark)');
if (mql.matches) {
colorScheme = dark
} else {
colorScheme = light
}
}
if (mode === 'light') {
colorScheme = light;
}
if (mode === 'dark') {
colorScheme = dark;
}
if (colorScheme) {
${setter}
}
} catch(e){}})();`,
}}
/>
);
}

View File

@@ -0,0 +1,2 @@
export { default } from './InitColorSchemeScript';
export type { InitColorSchemeScriptProps } from './InitColorSchemeScript';

View File

@@ -0,0 +1,11 @@
import * as React from 'react';
interface RtlProviderProps {
children?: React.ReactNode;
value?: boolean;
}
declare const RtlProvider: React.FC<RtlProviderProps>;
export const useRtl: () => boolean;
export default RtlProvider;

View File

@@ -0,0 +1,21 @@
'use client';
import * as React from 'react';
import PropTypes from 'prop-types';
const RtlContext = React.createContext();
function RtlProvider({ value, ...props }) {
return <RtlContext.Provider value={value ?? true} {...props} />;
}
RtlProvider.propTypes = {
children: PropTypes.node,
value: PropTypes.bool,
};
export const useRtl = () => {
const value = React.useContext(RtlContext);
return value ?? false;
};
export default RtlProvider;

View File

@@ -0,0 +1,527 @@
import { expect } from 'chai';
import { createRenderer } from '@mui/internal-test-utils';
import Stack from '@mui/system/Stack';
import createTheme from '@mui/system/createTheme';
import { style } from './createStack';
import describeConformance from '../../test/describeConformance';
describe('<Stack />', () => {
const { render } = createRenderer();
describeConformance(<Stack />, () => ({
render,
inheritComponent: 'div',
refInstanceof: window.HTMLDivElement,
muiName: 'MuiStack',
skip: ['componentProp', 'componentsProp', 'rootClass', 'themeVariants', 'themeStyleOverrides'],
}));
const theme = createTheme();
it('should handle breakpoints with a missing key', () => {
expect(
style({
ownerState: {
direction: { xs: 'column', sm: 'row' },
spacing: { xs: 1, sm: 2, md: 4 },
},
theme,
}),
).to.deep.equal({
'@media (min-width:0px)': {
'& > :not(style):not(style)': {
margin: 0,
},
'& > :not(style) ~ :not(style)': {
marginTop: '8px',
},
flexDirection: 'column',
},
[`@media (min-width:${theme.breakpoints.values.sm}px)`]: {
'& > :not(style):not(style)': {
margin: 0,
},
'& > :not(style) ~ :not(style)': {
marginLeft: '16px',
},
flexDirection: 'row',
},
[`@media (min-width:${theme.breakpoints.values.md}px)`]: {
'& > :not(style):not(style)': {
margin: 0,
},
'& > :not(style) ~ :not(style)': {
marginLeft: '32px',
},
},
display: 'flex',
flexDirection: 'column',
});
});
it('should handle direction with multiple keys and spacing with one', () => {
expect(
style({
ownerState: {
direction: { sm: 'column', md: 'row' },
spacing: 2,
},
theme,
}),
).to.deep.equal({
[`@media (min-width:${theme.breakpoints.values.sm}px)`]: {
'& > :not(style):not(style)': {
margin: 0,
},
'& > :not(style) ~ :not(style)': {
marginTop: '16px',
},
flexDirection: 'column',
},
[`@media (min-width:${theme.breakpoints.values.md}px)`]: {
'& > :not(style):not(style)': {
margin: 0,
},
'& > :not(style) ~ :not(style)': {
marginLeft: '16px',
},
flexDirection: 'row',
},
display: 'flex',
flexDirection: 'column',
});
});
it('should handle spacing with multiple keys and direction with one', () => {
expect(
style({
ownerState: {
direction: 'column',
spacing: { sm: 2, md: 4 },
},
theme,
}),
).to.deep.equal({
[`@media (min-width:${theme.breakpoints.values.sm}px)`]: {
'& > :not(style):not(style)': {
margin: 0,
},
'& > :not(style) ~ :not(style)': {
marginTop: '16px',
},
},
[`@media (min-width:${theme.breakpoints.values.md}px)`]: {
'& > :not(style):not(style)': {
margin: 0,
},
'& > :not(style) ~ :not(style)': {
marginTop: '32px',
},
},
display: 'flex',
flexDirection: 'column',
});
});
it('should handle spacing with multiple keys and null values', () => {
expect(
style({
ownerState: {
direction: 'column',
spacing: { sm: 2, md: 0, lg: 4 },
},
theme,
}),
).to.deep.equal({
[`@media (min-width:${theme.breakpoints.values.sm}px)`]: {
'& > :not(style):not(style)': {
margin: 0,
},
'& > :not(style) ~ :not(style)': {
marginTop: '16px',
},
},
[`@media (min-width:${theme.breakpoints.values.md}px)`]: {
'& > :not(style):not(style)': {
margin: 0,
},
'& > :not(style) ~ :not(style)': {
marginTop: '0px',
},
},
[`@media (min-width:${theme.breakpoints.values.lg}px)`]: {
'& > :not(style):not(style)': {
margin: 0,
},
'& > :not(style) ~ :not(style)': {
marginTop: '32px',
},
},
display: 'flex',
flexDirection: 'column',
});
});
it('should handle flat params', () => {
expect(
style({
ownerState: {
direction: 'row',
spacing: 3,
},
theme,
}),
).to.deep.equal({
'& > :not(style):not(style)': {
margin: 0,
},
'& > :not(style) ~ :not(style)': {
marginLeft: '24px',
},
display: 'flex',
flexDirection: 'row',
});
});
it('should respect the theme breakpoints order', () => {
expect(
style({
ownerState: {
direction: { xs: 'column' },
spacing: { lg: 2, xs: 1 },
},
theme,
}),
).to.deep.equal({
'@media (min-width:0px)': {
'& > :not(style):not(style)': {
margin: 0,
},
'& > :not(style) ~ :not(style)': {
marginTop: '8px',
},
flexDirection: 'column',
},
[`@media (min-width:${theme.breakpoints.values.lg}px)`]: {
'& > :not(style):not(style)': {
margin: 0,
},
'& > :not(style) ~ :not(style)': {
marginTop: '16px',
},
},
display: 'flex',
flexDirection: 'column',
});
});
describe('prop: direction', () => {
it('should generate correct direction given string values', () => {
expect(
style({
ownerState: {
direction: 'column-reverse',
spacing: 1,
},
theme,
}),
).to.deep.equal({
'& > :not(style):not(style)': {
margin: 0,
},
'& > :not(style) ~ :not(style)': {
marginBottom: '8px',
},
display: 'flex',
flexDirection: 'column-reverse',
});
});
it('should generate correct responsive styles regardless of breakpoints order', () => {
expect(
style({
ownerState: {
direction: { sm: 'row', xs: 'column' },
spacing: { xs: 1, sm: 2, md: 3 },
},
theme,
}),
).to.deep.equal({
'@media (min-width:0px)': {
'& > :not(style):not(style)': {
margin: 0,
},
'& > :not(style) ~ :not(style)': {
marginTop: '8px',
},
flexDirection: 'column',
},
[`@media (min-width:${theme.breakpoints.values.sm}px)`]: {
'& > :not(style):not(style)': {
margin: 0,
},
'& > :not(style) ~ :not(style)': {
marginLeft: '16px',
},
flexDirection: 'row',
},
[`@media (min-width:${theme.breakpoints.values.md}px)`]: {
'& > :not(style):not(style)': {
margin: 0,
},
'& > :not(style) ~ :not(style)': {
marginLeft: '24px',
},
},
display: 'flex',
flexDirection: 'column',
});
});
it('should generate correct direction even though breakpoints are not fully provided', () => {
expect(
style({
ownerState: {
direction: { lg: 'row' },
},
theme,
}),
).to.deep.equal({
[`@media (min-width:${theme.breakpoints.values.lg}px)`]: {
flexDirection: 'row',
},
display: 'flex',
flexDirection: 'column',
});
});
it('should place correct margin direction even though breakpoints are not fully provided', () => {
expect(
style({
ownerState: {
direction: { lg: 'row' },
spacing: { xs: 0, md: 2, xl: 4 },
},
theme,
}),
).to.deep.equal({
[`@media (min-width:${theme.breakpoints.values.xs}px)`]: {
'& > :not(style):not(style)': {
margin: 0,
},
'& > :not(style) ~ :not(style)': {
marginTop: '0px',
},
},
[`@media (min-width:${theme.breakpoints.values.md}px)`]: {
'& > :not(style):not(style)': {
margin: 0,
},
'& > :not(style) ~ :not(style)': {
marginTop: '16px',
},
},
[`@media (min-width:${theme.breakpoints.values.lg}px)`]: {
'& > :not(style):not(style)': {
margin: 0,
},
'& > :not(style) ~ :not(style)': {
marginLeft: '16px',
},
flexDirection: 'row',
},
[`@media (min-width:${theme.breakpoints.values.xl}px)`]: {
'& > :not(style):not(style)': {
margin: 0,
},
'& > :not(style) ~ :not(style)': {
marginLeft: '32px',
},
},
display: 'flex',
flexDirection: 'column',
});
expect(
style({
ownerState: {
direction: { lg: 'column', sm: 'row' },
spacing: { md: 2, xl: 4, xs: 0 },
},
theme,
}),
).to.deep.equal({
[`@media (min-width:${theme.breakpoints.values.xs}px)`]: {
'& > :not(style):not(style)': {
margin: 0,
},
'& > :not(style) ~ :not(style)': {
marginTop: '0px',
},
},
[`@media (min-width:${theme.breakpoints.values.sm}px)`]: {
'& > :not(style):not(style)': {
margin: 0,
},
'& > :not(style) ~ :not(style)': {
marginLeft: '0px',
},
flexDirection: 'row',
},
[`@media (min-width:${theme.breakpoints.values.md}px)`]: {
'& > :not(style):not(style)': {
margin: 0,
},
'& > :not(style) ~ :not(style)': {
marginLeft: '16px',
},
},
[`@media (min-width:${theme.breakpoints.values.lg}px)`]: {
'& > :not(style):not(style)': {
margin: 0,
},
'& > :not(style) ~ :not(style)': {
marginTop: '16px',
},
flexDirection: 'column',
},
[`@media (min-width:${theme.breakpoints.values.xl}px)`]: {
'& > :not(style):not(style)': {
margin: 0,
},
'& > :not(style) ~ :not(style)': {
marginTop: '32px',
},
},
display: 'flex',
flexDirection: 'column',
});
});
});
describe('prop: spacing', () => {
it('should generate correct responsive styles regardless of breakpoints order', () => {
expect(
style({
ownerState: {
direction: 'column',
spacing: { sm: 2, md: 3, xs: 1 },
},
theme,
}),
).to.deep.equal({
'@media (min-width:0px)': {
'& > :not(style):not(style)': {
margin: 0,
},
'& > :not(style) ~ :not(style)': {
marginTop: '8px',
},
},
[`@media (min-width:${theme.breakpoints.values.sm}px)`]: {
'& > :not(style):not(style)': {
margin: 0,
},
'& > :not(style) ~ :not(style)': {
marginTop: '16px',
},
},
[`@media (min-width:${theme.breakpoints.values.md}px)`]: {
'& > :not(style):not(style)': {
margin: 0,
},
'& > :not(style) ~ :not(style)': {
marginTop: '24px',
},
},
display: 'flex',
flexDirection: 'column',
});
});
it('should generate correct styles if custom breakpoints are provided in theme', () => {
const customTheme = createTheme({
breakpoints: {
values: {
smallest: 0,
small: 375,
mobile: 600,
tablet: 992,
desktop: 1200,
},
},
});
expect(
style({
ownerState: {
direction: 'column',
spacing: 4,
},
theme: customTheme,
}),
).to.deep.equal({
'& > :not(style):not(style)': {
margin: 0,
},
'& > :not(style) ~ :not(style)': {
marginTop: '32px',
},
display: 'flex',
flexDirection: 'column',
});
});
it('should generate correct responsive styles if custom responsive spacing values are provided', () => {
const customTheme = createTheme({
breakpoints: {
values: {
smallest: 0,
small: 375,
mobile: 600,
tablet: 992,
desktop: 1200,
},
},
});
expect(
style({
ownerState: {
direction: 'column',
spacing: { small: 4 },
},
theme: customTheme,
}),
).to.deep.equal({
[`@media (min-width:${customTheme.breakpoints.values.small}px)`]: {
'& > :not(style):not(style)': {
margin: 0,
},
'& > :not(style) ~ :not(style)': {
marginTop: '32px',
},
},
display: 'flex',
flexDirection: 'column',
});
});
it('should list responsive styles in correct order', () => {
const styles = style({
ownerState: {
direction: { xs: 'column', lg: 'row' },
spacing: { xs: 0, md: 2, xl: 4 },
},
theme,
});
const keysForResponsiveStyles = Object.keys(styles).filter((prop) => prop.includes('@media'));
expect(keysForResponsiveStyles).to.deep.equal([
'@media (min-width:0px)',
'@media (min-width:900px)',
'@media (min-width:1200px)',
'@media (min-width:1536px)',
]);
});
});
});

View File

@@ -0,0 +1,76 @@
'use client';
import PropTypes from 'prop-types';
import createStack from './createStack';
/**
*
* Demos:
*
* - [Stack (Joy UI)](https://mui.com/joy-ui/react-stack/)
* - [Stack (Material UI)](https://mui.com/material-ui/react-stack/)
* - [Stack (MUI System)](https://mui.com/system/react-stack/)
*
* API:
*
* - [Stack API](https://mui.com/system/api/stack/)
*/
const Stack = createStack();
Stack.propTypes /* remove-proptypes */ = {
// ┌────────────────────────────── Warning ──────────────────────────────┐
// │ These PropTypes are generated from the TypeScript type definitions. │
// │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
// └─────────────────────────────────────────────────────────────────────┘
/**
* The content of the component.
*/
children: PropTypes.node,
/**
* The component used for the root node.
* Either a string to use a HTML element or a component.
*/
component: PropTypes.elementType,
/**
* Defines the `flex-direction` style property.
* It is applied for all screen sizes.
* @default 'column'
*/
direction: PropTypes.oneOfType([
PropTypes.oneOf(['column-reverse', 'column', 'row-reverse', 'row']),
PropTypes.arrayOf(PropTypes.oneOf(['column-reverse', 'column', 'row-reverse', 'row'])),
PropTypes.object,
]),
/**
* Add an element between each child.
*/
divider: PropTypes.node,
/**
* Defines the space between immediate children.
* @default 0
*/
spacing: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])),
PropTypes.number,
PropTypes.object,
PropTypes.string,
]),
/**
* The system prop, which allows defining system overrides as well as additional CSS styles.
*/
sx: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])),
PropTypes.func,
PropTypes.object,
]),
/**
* If `true`, the CSS flexbox `gap` is used instead of applying `margin` to children.
*
* While CSS `gap` removes the [known limitations](https://mui.com/joy-ui/react-stack/#limitations),
* it is not fully supported in some browsers. We recommend checking https://caniuse.com/?search=flex%20gap before using this flag.
*
* To enable this flag globally, follow the theme's default props configuration.
* @default false
*/
useFlexGap: PropTypes.bool,
} as any;
export default Stack;

View File

@@ -0,0 +1,63 @@
import * as React from 'react';
import { OverrideProps } from '@mui/types';
import { ResponsiveStyleValue, SxProps } from '../styleFunctionSx';
import { SystemProps } from '../Box';
import { Theme } from '../createTheme';
export interface StackBaseProps {
/**
* The content of the component.
*/
children?: React.ReactNode;
/**
* Defines the `flex-direction` style property.
* It is applied for all screen sizes.
* @default 'column'
*/
direction?: ResponsiveStyleValue<'row' | 'row-reverse' | 'column' | 'column-reverse'>;
/**
* Defines the space between immediate children.
* @default 0
*/
spacing?: ResponsiveStyleValue<number | string>;
/**
* Add an element between each child.
*/
divider?: React.ReactNode;
/**
* If `true`, the CSS flexbox `gap` is used instead of applying `margin` to children.
*
* While CSS `gap` removes the [known limitations](https://mui.com/joy-ui/react-stack/#limitations),
* it is not fully supported in some browsers. We recommend checking https://caniuse.com/?search=flex%20gap before using this flag.
*
* To enable this flag globally, follow the theme's default props configuration.
* @default false
*/
useFlexGap?: boolean;
}
export interface StackTypeMap<
AdditionalProps = {},
DefaultComponent extends React.ElementType = 'div',
> {
props: AdditionalProps &
StackBaseProps & {
/**
* The system prop, which allows defining system overrides as well as additional CSS styles.
*/
sx?: SxProps<Theme>;
} & SystemProps<Theme>;
defaultComponent: DefaultComponent;
}
export type StackProps<
RootComponent extends React.ElementType = StackTypeMap['defaultComponent'],
AdditionalProps = {
component?: React.ElementType;
},
> = OverrideProps<StackTypeMap<AdditionalProps, RootComponent>, RootComponent>;
export interface StackOwnerState {
direction: StackProps['direction'];
spacing: StackProps['spacing'];
useFlexGap: boolean;
}

View File

@@ -0,0 +1,239 @@
'use client';
import * as React from 'react';
import PropTypes from 'prop-types';
import clsx from 'clsx';
import { OverridableComponent } from '@mui/types';
import deepmerge from '@mui/utils/deepmerge';
import generateUtilityClass from '@mui/utils/generateUtilityClass';
import composeClasses from '@mui/utils/composeClasses';
import systemStyled from '../styled';
import useThemePropsSystem from '../useThemeProps';
import { extendSxProp } from '../styleFunctionSx';
import createTheme from '../createTheme';
import { CreateMUIStyled } from '../createStyled';
import { StackTypeMap, StackOwnerState } from './StackProps';
import type { Breakpoint } from '../createTheme';
import { Breakpoints } from '../createBreakpoints/createBreakpoints';
import {
handleBreakpoints,
mergeBreakpointsInOrder,
resolveBreakpointValues,
} from '../breakpoints';
import { createUnarySpacing, getValue } from '../spacing';
import { Spacing } from '../createTheme/createSpacing';
const defaultTheme = createTheme();
interface StyleFunctionProps {
theme: { breakpoints: Breakpoints; spacing: Spacing };
ownerState: StackOwnerState;
}
// widening Theme to any so that the consumer can own the theme structure.
const defaultCreateStyledComponent = (systemStyled as CreateMUIStyled<any>)('div', {
name: 'MuiStack',
slot: 'Root',
});
function useThemePropsDefault<T extends {}>(props: T) {
return useThemePropsSystem({
props,
name: 'MuiStack',
defaultTheme,
});
}
/**
* Return an array with the separator React element interspersed between
* each React node of the input children.
*
* > joinChildren([1,2,3], 0)
* [1,0,2,0,3]
*/
function joinChildren(children: React.ReactNode, separator: React.ReactElement<unknown>) {
const childrenArray = React.Children.toArray(children).filter(Boolean);
return childrenArray.reduce<React.ReactNode[]>((output, child, index) => {
output.push(child);
if (index < childrenArray.length - 1) {
output.push(React.cloneElement(separator, { key: `separator-${index}` }));
}
return output;
}, []);
}
const getSideFromDirection = (direction: StackOwnerState['direction']) => {
return {
row: 'Left',
'row-reverse': 'Right',
column: 'Top',
'column-reverse': 'Bottom',
}[direction as string];
};
export const style = ({ ownerState, theme }: StyleFunctionProps) => {
let styles = {
display: 'flex',
flexDirection: 'column',
...handleBreakpoints(
{ theme },
resolveBreakpointValues({
values: ownerState.direction,
breakpoints: theme.breakpoints.values,
}),
(propValue: string) => ({
flexDirection: propValue,
}),
),
};
if (ownerState.spacing) {
const transformer = createUnarySpacing(theme);
const base = Object.keys(theme.breakpoints.values).reduce<Record<string, boolean>>(
(acc, breakpoint) => {
if (
(typeof ownerState.spacing === 'object' &&
(ownerState.spacing as any)[breakpoint] != null) ||
(typeof ownerState.direction === 'object' &&
(ownerState.direction as any)[breakpoint] != null)
) {
acc[breakpoint] = true;
}
return acc;
},
{},
);
const directionValues = resolveBreakpointValues({
values: ownerState.direction,
base,
});
const spacingValues = resolveBreakpointValues({
values: ownerState.spacing,
base,
});
if (typeof directionValues === 'object') {
Object.keys(directionValues).forEach((breakpoint, index, breakpoints) => {
const directionValue = directionValues[breakpoint];
if (!directionValue) {
const previousDirectionValue =
index > 0 ? directionValues[breakpoints[index - 1]] : 'column';
directionValues[breakpoint] = previousDirectionValue;
}
});
}
const styleFromPropValue = (propValue: string | number | null, breakpoint?: Breakpoint) => {
if (ownerState.useFlexGap) {
return { gap: getValue(transformer, propValue) };
}
return {
// The useFlexGap={false} implement relies on each child to give up control of the margin.
// We need to reset the margin to avoid double spacing.
'& > :not(style):not(style)': {
margin: 0,
},
'& > :not(style) ~ :not(style)': {
[`margin${getSideFromDirection(
breakpoint ? directionValues[breakpoint] : ownerState.direction,
)}`]: getValue(transformer, propValue),
},
};
};
styles = deepmerge(styles, handleBreakpoints({ theme }, spacingValues, styleFromPropValue));
}
styles = mergeBreakpointsInOrder(theme.breakpoints, styles);
return styles;
};
export default function createStack(
options: {
createStyledComponent?: typeof defaultCreateStyledComponent;
useThemeProps?: typeof useThemePropsDefault;
componentName?: string;
} = {},
) {
const {
// This will allow adding custom styled fn (for example for custom sx style function)
createStyledComponent = defaultCreateStyledComponent,
useThemeProps = useThemePropsDefault,
componentName = 'MuiStack',
} = options;
const useUtilityClasses = () => {
const slots = {
root: ['root'],
};
return composeClasses(slots, (slot) => generateUtilityClass(componentName, slot), {});
};
const StackRoot = createStyledComponent<{
ownerState: StackOwnerState;
}>(style);
const Stack = React.forwardRef(function Grid(inProps, ref) {
const themeProps = useThemeProps<typeof inProps & { component?: React.ElementType }>(inProps);
const props = extendSxProp(themeProps) as Omit<typeof themeProps, 'color'>; // `color` type conflicts with html color attribute.
const {
component = 'div',
direction = 'column',
spacing = 0,
divider,
children,
className,
useFlexGap = false,
...other
} = props;
const ownerState = {
direction,
spacing,
useFlexGap,
};
const classes = useUtilityClasses();
return (
<StackRoot
as={component}
ownerState={ownerState}
ref={ref}
className={clsx(classes.root, className)}
{...other}
>
{divider ? joinChildren(children, divider as React.ReactElement<unknown>) : children}
</StackRoot>
);
}) as OverridableComponent<StackTypeMap>;
Stack.propTypes /* remove-proptypes */ = {
children: PropTypes.node,
direction: PropTypes.oneOfType([
PropTypes.oneOf(['column-reverse', 'column', 'row-reverse', 'row']),
PropTypes.arrayOf(PropTypes.oneOf(['column-reverse', 'column', 'row-reverse', 'row'])),
PropTypes.object,
]),
divider: PropTypes.node,
spacing: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])),
PropTypes.number,
PropTypes.object,
PropTypes.string,
]),
sx: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])),
PropTypes.func,
PropTypes.object,
]),
};
return Stack;
}

View File

@@ -0,0 +1,6 @@
export { default } from './Stack';
export { default as createStack } from './createStack';
export * from './StackProps';
export { default as stackClasses } from './stackClasses';
export * from './stackClasses';

View File

@@ -0,0 +1,17 @@
import generateUtilityClasses from '@mui/utils/generateUtilityClasses';
import generateUtilityClass from '@mui/utils/generateUtilityClass';
export interface StackClasses {
/** Styles applied to the root element. */
root: string;
}
export type StackClassKey = keyof StackClasses;
export function getStackUtilityClass(slot: string): string {
return generateUtilityClass('MuiStack', slot);
}
const stackClasses: StackClasses = generateUtilityClasses('MuiStack', ['root']);
export default stackClasses;

View File

@@ -0,0 +1,27 @@
import { DefaultTheme } from '@mui/private-theming';
export interface ThemeProviderProps<Theme = DefaultTheme> {
/**
* Your component tree.
*/
children?: React.ReactNode;
/**
* The design system's unique id for getting the corresponded theme when there are multiple design systems.
*/
themeId?: string;
/**
* A theme object. You can provide a function to extend the outer theme.
*/
theme: Partial<Theme> | ((outerTheme: Theme) => Theme);
}
/**
* This component makes the `theme` available down the React tree.
* It should preferably be used at **the root of your component tree**.
* API:
*
* - [ThemeProvider API](https://mui.com/material-ui/customization/theming/#themeprovider)
*/
export default function ThemeProvider<T = DefaultTheme>(
props: ThemeProviderProps<T>,
): React.ReactElement<ThemeProviderProps<T>>;

View File

@@ -0,0 +1,110 @@
'use client';
import * as React from 'react';
import PropTypes from 'prop-types';
import {
ThemeProvider as MuiThemeProvider,
useTheme as usePrivateTheme,
} from '@mui/private-theming';
import exactProp from '@mui/utils/exactProp';
import { ThemeContext as StyledEngineThemeContext } from '@mui/styled-engine';
import useThemeWithoutDefault from '../useThemeWithoutDefault';
import RtlProvider from '../RtlProvider';
import DefaultPropsProvider from '../DefaultPropsProvider';
import useLayerOrder from './useLayerOrder';
const EMPTY_THEME = {};
function useThemeScoping(themeId, upperTheme, localTheme, isPrivate = false) {
return React.useMemo(() => {
const resolvedTheme = themeId ? upperTheme[themeId] || upperTheme : upperTheme;
if (typeof localTheme === 'function') {
const mergedTheme = localTheme(resolvedTheme);
const result = themeId ? { ...upperTheme, [themeId]: mergedTheme } : mergedTheme;
// must return a function for the private theme to NOT merge with the upper theme.
// see the test case "use provided theme from a callback" in ThemeProvider.test.js
if (isPrivate) {
return () => result;
}
return result;
}
return themeId ? { ...upperTheme, [themeId]: localTheme } : { ...upperTheme, ...localTheme };
}, [themeId, upperTheme, localTheme, isPrivate]);
}
/**
* This component makes the `theme` available down the React tree.
* It should preferably be used at **the root of your component tree**.
*
* <ThemeProvider theme={theme}> // existing use case
* <ThemeProvider theme={{ id: theme }}> // theme scoping
*/
function ThemeProvider(props) {
const { children, theme: localTheme, themeId } = props;
const upperTheme = useThemeWithoutDefault(EMPTY_THEME);
const upperPrivateTheme = usePrivateTheme() || EMPTY_THEME;
if (process.env.NODE_ENV !== 'production') {
if (
(upperTheme === null && typeof localTheme === 'function') ||
(themeId && upperTheme && !upperTheme[themeId] && typeof localTheme === 'function')
) {
console.error(
[
'MUI: You are providing a theme function prop to the ThemeProvider component:',
'<ThemeProvider theme={outerTheme => outerTheme} />',
'',
'However, no outer theme is present.',
'Make sure a theme is already injected higher in the React tree ' +
'or provide a theme object.',
].join('\n'),
);
}
}
const engineTheme = useThemeScoping(themeId, upperTheme, localTheme);
const privateTheme = useThemeScoping(themeId, upperPrivateTheme, localTheme, true);
const rtlValue = (themeId ? engineTheme[themeId] : engineTheme).direction === 'rtl';
const layerOrder = useLayerOrder(engineTheme);
return (
<MuiThemeProvider theme={privateTheme}>
<StyledEngineThemeContext.Provider value={engineTheme}>
<RtlProvider value={rtlValue}>
<DefaultPropsProvider
value={themeId ? engineTheme[themeId].components : engineTheme.components}
>
{layerOrder}
{children}
</DefaultPropsProvider>
</RtlProvider>
</StyledEngineThemeContext.Provider>
</MuiThemeProvider>
);
}
ThemeProvider.propTypes /* remove-proptypes */ = {
// ┌────────────────────────────── Warning ──────────────────────────────┐
// │ These PropTypes are generated from the TypeScript type definitions. │
// │ To update them, edit the d.ts file and run `pnpm proptypes`. │
// └─────────────────────────────────────────────────────────────────────┘
/**
* Your component tree.
*/
children: PropTypes.node,
/**
* A theme object. You can provide a function to extend the outer theme.
*/
theme: PropTypes.oneOfType([PropTypes.func, PropTypes.object]).isRequired,
/**
* The design system's unique id for getting the corresponded theme when there are multiple design systems.
*/
themeId: PropTypes.string,
};
if (process.env.NODE_ENV !== 'production') {
ThemeProvider.propTypes = exactProp(ThemeProvider.propTypes);
}
export default ThemeProvider;

View File

@@ -0,0 +1,334 @@
import * as React from 'react';
import { expect } from 'chai';
import { createRenderer } from '@mui/internal-test-utils';
import { useTheme as usePrivateTheme } from '@mui/private-theming';
import { ThemeContext } from '@mui/styled-engine';
import { ThemeProvider } from '@mui/system';
import { useRtl } from '@mui/system/RtlProvider';
import { useDefaultProps } from '../DefaultPropsProvider';
const useEngineTheme = () => React.useContext(ThemeContext);
describe('ThemeProvider', () => {
const { render } = createRenderer();
it('should provide the theme to the mui theme context', () => {
let privateTheme;
let engineTheme;
function Test() {
// TODO: uncomment once we enable eslint-plugin-react-compiler // eslint-disable-next-line react-compiler/react-compiler -- privateTheme is required outside the component
privateTheme = usePrivateTheme();
// TODO: uncomment once we enable eslint-plugin-react-compiler // eslint-disable-next-line react-compiler/react-compiler -- engineTheme is required outside the component
engineTheme = useEngineTheme();
return null;
}
render(
<ThemeProvider theme={{ foo: 'foo' }}>
<Test />
</ThemeProvider>,
);
expect(privateTheme).to.include({ foo: 'foo' });
expect(engineTheme).to.include({ foo: 'foo' });
});
it('should provide the theme to the styled engine theme context', () => {
let privateTheme;
let engineTheme;
function Test() {
// TODO: uncomment once we enable eslint-plugin-react-compiler // eslint-disable-next-line react-compiler/react-compiler -- privateTheme is required outside the component
privateTheme = usePrivateTheme();
// TODO: uncomment once we enable eslint-plugin-react-compiler // eslint-disable-next-line react-compiler/react-compiler -- engineTheme is required outside the component
engineTheme = useEngineTheme();
return null;
}
render(
<ThemeProvider theme={{ foo: 'foo' }}>
<Test />
</ThemeProvider>,
);
expect(engineTheme).to.include({ foo: 'foo' });
expect(privateTheme).to.include({ foo: 'foo' });
});
it('merge theme by default', () => {
let privateTheme;
let engineTheme;
function Test() {
// TODO: uncomment once we enable eslint-plugin-react-compiler // eslint-disable-next-line react-compiler/react-compiler -- privateTheme is required outside the component
privateTheme = usePrivateTheme();
// TODO: uncomment once we enable eslint-plugin-react-compiler // eslint-disable-next-line react-compiler/react-compiler -- engineTheme is required outside the component
engineTheme = useEngineTheme();
return null;
}
render(
<ThemeProvider theme={{ foo: 'foo' }}>
<ThemeProvider theme={{ bar: 'bar' }}>
<Test />
</ThemeProvider>
</ThemeProvider>,
);
expect(privateTheme).to.deep.equal({
foo: 'foo',
bar: 'bar',
[Symbol.for('mui.nested')]: true,
});
expect(engineTheme).to.deep.equal({
foo: 'foo',
bar: 'bar',
});
});
it('use provided theme from a callback', () => {
let privateTheme;
let engineTheme;
function Test() {
// TODO: uncomment once we enable eslint-plugin-react-compiler // eslint-disable-next-line react-compiler/react-compiler -- privateTheme is required outside the component
privateTheme = usePrivateTheme();
// TODO: uncomment once we enable eslint-plugin-react-compiler // eslint-disable-next-line react-compiler/react-compiler -- engineTheme is required outside the component
engineTheme = useEngineTheme();
return null;
}
render(
<ThemeProvider theme={{ foo: 'foo' }}>
<ThemeProvider theme={(upperTheme) => ({ bar: upperTheme })}>
<Test />
</ThemeProvider>
</ThemeProvider>,
);
expect(privateTheme).to.deep.equal({
bar: { foo: 'foo', [Symbol.for('mui.nested')]: false },
[Symbol.for('mui.nested')]: true,
});
expect(engineTheme).to.deep.equal({
bar: { foo: 'foo' },
});
});
it('theme scope: theme should not change', () => {
let privateTheme;
let engineTheme;
function Test() {
// TODO: uncomment once we enable eslint-plugin-react-compiler // eslint-disable-next-line react-compiler/react-compiler -- privateTheme is required outside the component
privateTheme = usePrivateTheme();
// TODO: uncomment once we enable eslint-plugin-react-compiler // eslint-disable-next-line react-compiler/react-compiler -- engineTheme is required outside the component
engineTheme = useEngineTheme();
return null;
}
render(
<ThemeProvider themeId="mui" theme={{ foo: 'foo' }}>
<Test />
</ThemeProvider>,
);
expect(privateTheme).to.deep.equal({ mui: { foo: 'foo' }, [Symbol.for('mui.nested')]: false });
expect(engineTheme).to.deep.equal({ mui: { foo: 'foo' } });
});
it('theme scope: nested below general theme', () => {
let privateTheme;
let engineTheme;
function Test() {
// TODO: uncomment once we enable eslint-plugin-react-compiler // eslint-disable-next-line react-compiler/react-compiler -- privateTheme is required outside the component
privateTheme = usePrivateTheme();
// TODO: uncomment once we enable eslint-plugin-react-compiler // eslint-disable-next-line react-compiler/react-compiler -- engineTheme is required outside the component
engineTheme = useEngineTheme();
return null;
}
render(
<ThemeProvider theme={{ foo: 'foo' }}>
<ThemeProvider themeId="mui" theme={{ bar: 'bar' }}>
<Test />
</ThemeProvider>
</ThemeProvider>,
);
expect(privateTheme).to.deep.equal({
foo: 'foo',
mui: { bar: 'bar' },
[Symbol.for('mui.nested')]: true,
});
expect(engineTheme).to.deep.equal({
foo: 'foo',
mui: { bar: 'bar' },
});
});
it('theme scope: respect callback and merge theme', () => {
let privateTheme;
let engineTheme;
function Test() {
// TODO: uncomment once we enable eslint-plugin-react-compiler // eslint-disable-next-line react-compiler/react-compiler -- privateTheme is required outside the component
privateTheme = usePrivateTheme();
// TODO: uncomment once we enable eslint-plugin-react-compiler // eslint-disable-next-line react-compiler/react-compiler -- engineTheme is required outside the component
engineTheme = useEngineTheme();
return null;
}
render(
<ThemeProvider theme={{ foo: 'foo' }}>
<ThemeProvider themeId="mui" theme={{ bar: 'bar' }}>
<ThemeProvider themeId="mui" theme={(upperTheme) => ({ baz: upperTheme })}>
<Test />
</ThemeProvider>
</ThemeProvider>
</ThemeProvider>,
);
expect(privateTheme).to.deep.equal({
foo: 'foo',
mui: { baz: { bar: 'bar' } },
[Symbol.for('mui.nested')]: true,
});
expect(engineTheme).to.deep.equal({
foo: 'foo',
mui: { baz: { bar: 'bar' } },
});
});
it('theme scope: order should not matter', () => {
let privateTheme;
let engineTheme;
function Test() {
// TODO: uncomment once we enable eslint-plugin-react-compiler // eslint-disable-next-line react-compiler/react-compiler -- privateTheme is required outside the component
privateTheme = usePrivateTheme();
// TODO: uncomment once we enable eslint-plugin-react-compiler // eslint-disable-next-line react-compiler/react-compiler -- engineTheme is required outside the component
engineTheme = useEngineTheme();
return null;
}
render(
<ThemeProvider themeId="mui" theme={{ bar: 'bar' }}>
<ThemeProvider theme={{ foo: 'foo' }}>
<ThemeProvider themeId="mui" theme={(upperTheme) => ({ baz: upperTheme })}>
<Test />
</ThemeProvider>
</ThemeProvider>
</ThemeProvider>,
);
expect(privateTheme).to.deep.equal({
foo: 'foo',
mui: { baz: { bar: 'bar' } },
[Symbol.for('mui.nested')]: true,
});
expect(engineTheme).to.deep.equal({
foo: 'foo',
mui: { baz: { bar: 'bar' } },
});
});
it('theme scope: multiple themeIds', () => {
let privateTheme;
let engineTheme;
function Test() {
// TODO: uncomment once we enable eslint-plugin-react-compiler // eslint-disable-next-line react-compiler/react-compiler -- privateTheme is required outside the component
privateTheme = usePrivateTheme();
// TODO: uncomment once we enable eslint-plugin-react-compiler // eslint-disable-next-line react-compiler/react-compiler -- engineTheme is required outside the component
engineTheme = useEngineTheme();
return null;
}
let privateTheme2;
let engineTheme2;
function Test2() {
privateTheme2 = usePrivateTheme();
engineTheme2 = useEngineTheme();
return null;
}
render(
<ThemeProvider themeId="mui" theme={{ bar: 'bar' }}>
<ThemeProvider themeId="joy" theme={{ foo: 'foo' }}>
<Test />
<ThemeProvider themeId="mui" theme={(upperTheme) => ({ baz: upperTheme })}>
<ThemeProvider themeId="joy" theme={(upperTheme) => ({ baz: upperTheme })}>
<Test2 />
</ThemeProvider>
</ThemeProvider>
</ThemeProvider>
</ThemeProvider>,
);
expect(privateTheme).to.deep.equal({
mui: { bar: 'bar' },
joy: { foo: 'foo' },
[Symbol.for('mui.nested')]: true,
});
expect(privateTheme2).to.deep.equal({
mui: { baz: { bar: 'bar' } },
joy: { baz: { foo: 'foo' } },
[Symbol.for('mui.nested')]: true,
});
expect(engineTheme).to.deep.equal({
mui: { bar: 'bar' },
joy: { foo: 'foo' },
});
expect(engineTheme2).to.deep.equal({
mui: { baz: { bar: 'bar' } },
joy: { baz: { foo: 'foo' } },
});
});
it('theme scope: multiple themeIds with callback', () => {
expect(() =>
render(
<ThemeProvider themeId="mui" theme={{ bar: 'bar' }}>
<ThemeProvider themeId="joy" theme={(upperTheme) => ({ foo: upperTheme })} />
</ThemeProvider>,
),
).toErrorDev([
'MUI: You are providing a theme function prop to the ThemeProvider component:',
'<ThemeProvider theme={outerTheme => outerTheme} />',
]);
});
it('theme scope: should pass scoped theme to DefaultPropsProvider', () => {
function Test(props) {
const defaultProps = useDefaultProps({ props, name: 'MuiTest' });
return defaultProps.text;
}
const { container } = render(
<ThemeProvider themeId="mui" theme={{ components: { MuiTest: { text: 'foo' } } }}>
<Test />
</ThemeProvider>,
);
expect(container.firstChild).to.have.text('foo');
});
it('sets the correct value for the RtlProvider based on the theme.direction', () => {
let rtlValue = null;
function Test() {
rtlValue = useRtl();
return null;
}
render(
<ThemeProvider theme={{ direction: 'rtl' }}>
<Test />
</ThemeProvider>,
);
expect(rtlValue).to.equal(true);
render(
<ThemeProvider theme={{ direction: 'ltr' }}>
<Test />
</ThemeProvider>,
);
expect(rtlValue).to.equal(false);
});
});

View File

@@ -0,0 +1,2 @@
export { default } from './ThemeProvider';
export * from './ThemeProvider';

View File

@@ -0,0 +1 @@
export { default } from './ThemeProvider';

View File

@@ -0,0 +1,57 @@
import { expect } from 'chai';
import { ThemeContext } from '@mui/styled-engine';
import { createRenderer } from '@mui/internal-test-utils';
import useLayerOrder from './useLayerOrder';
function TestComponent({ theme }: { theme: any }) {
const LayerOrder = useLayerOrder(theme);
return LayerOrder;
}
describe('useLayerOrder', () => {
const { render } = createRenderer();
afterEach(() => {
// Clean up any injected style tags
document.querySelectorAll('style[data-mui-layer-order]').forEach((el) => el.remove());
});
it('attach layer order', () => {
const theme = { modularCssLayers: true };
render(<TestComponent theme={theme} />);
expect(document.head.firstChild).not.to.equal(null);
expect(document.head.firstChild?.textContent).to.contain(
'@layer mui.global, mui.components, mui.theme, mui.custom, mui.sx;',
);
});
it('custom layer order string', () => {
const theme = { modularCssLayers: '@layer theme, base, mui, utilities;' };
render(<TestComponent theme={theme} />);
expect(document.head.firstChild?.textContent).to.contain(
'@layer theme, base, mui.global, mui.components, mui.theme, mui.custom, mui.sx, utilities;',
);
});
it('does not replace nested layer', () => {
const theme = { modularCssLayers: '@layer theme, base, mui.unknown, utilities;' };
render(<TestComponent theme={theme} />);
expect(document.head.firstChild?.textContent).to.contain(
'@layer theme, base, mui.unknown, utilities;',
);
});
it('returns null if modularCssLayers is falsy', () => {
render(<TestComponent theme={{}} />);
expect(document.head.firstChild?.nodeName).not.to.equal('STYLE');
});
it('do nothing if upperTheme exists to avoid duplicate elements', () => {
render(
<ThemeContext.Provider value={{ modularCssLayers: true }}>
<TestComponent theme={{}} />
</ThemeContext.Provider>,
);
expect(document.head.firstChild?.nodeName).not.to.equal('STYLE');
});
});

View File

@@ -0,0 +1,57 @@
import useEnhancedEffect from '@mui/utils/useEnhancedEffect';
import useId from '@mui/utils/useId';
import GlobalStyles from '../GlobalStyles';
import useThemeWithoutDefault from '../useThemeWithoutDefault';
/**
* This hook returns a `GlobalStyles` component that sets the CSS layer order (for server-side rendering).
* Then on client-side, it injects the CSS layer order into the document head to ensure that the layer order is always present first before other Emotion styles.
*/
export default function useLayerOrder(theme: { modularCssLayers?: boolean | string }) {
const upperTheme = useThemeWithoutDefault();
const id = useId() || '';
const { modularCssLayers } = theme;
let layerOrder = 'mui.global, mui.components, mui.theme, mui.custom, mui.sx';
if (!modularCssLayers || upperTheme !== null) {
// skip this hook if upper theme exists.
layerOrder = '';
} else if (typeof modularCssLayers === 'string') {
layerOrder = modularCssLayers.replace(/mui(?!\.)/g, layerOrder);
} else {
layerOrder = `@layer ${layerOrder};`;
}
useEnhancedEffect(() => {
const head = document.querySelector('head');
if (!head) {
return;
}
const firstChild = head.firstChild as HTMLElement | null;
if (layerOrder) {
// Only insert if first child doesn't have data-mui-layer-order attribute
if (
firstChild &&
firstChild.hasAttribute?.('data-mui-layer-order') &&
firstChild.getAttribute('data-mui-layer-order') === id
) {
return;
}
const styleElement = document.createElement('style');
styleElement.setAttribute('data-mui-layer-order', id);
styleElement.textContent = layerOrder;
head.prepend(styleElement);
} else {
head.querySelector(`style[data-mui-layer-order="${id}"]`)?.remove();
}
}, [layerOrder, id]);
if (!layerOrder) {
return null;
}
return <GlobalStyles styles={layerOrder} />;
}

View File

@@ -0,0 +1,27 @@
import { PropsFor, SimpleStyleFunction } from '../style';
export const border: SimpleStyleFunction<'border'>;
export const borderTop: SimpleStyleFunction<'borderTop'>;
export const borderRight: SimpleStyleFunction<'borderRight'>;
export const borderBottom: SimpleStyleFunction<'borderBottom'>;
export const borderLeft: SimpleStyleFunction<'borderLeft'>;
export const borderColor: SimpleStyleFunction<'borderColor'>;
export const borderTopColor: SimpleStyleFunction<'borderTopColor'>;
export const borderRightColor: SimpleStyleFunction<'borderRightColor'>;
export const borderBottomColor: SimpleStyleFunction<'borderBottomColor'>;
export const borderLeftColor: SimpleStyleFunction<'borderLeftColor'>;
export const borderRadius: SimpleStyleFunction<'borderRadius'>;
declare const borders: SimpleStyleFunction<
| 'border'
| 'borderTop'
| 'borderRight'
| 'borderBottom'
| 'borderLeft'
| 'borderColor'
| 'borderRadius'
>;
export type BordersProps = PropsFor<typeof borders>;
export default borders;

View File

@@ -0,0 +1,82 @@
import responsivePropType from '../responsivePropType';
import style from '../style';
import compose from '../compose';
import { createUnaryUnit, getValue } from '../spacing';
import { handleBreakpoints } from '../breakpoints';
export function borderTransform(value) {
if (typeof value !== 'number') {
return value;
}
return `${value}px solid`;
}
function createBorderStyle(prop, transform) {
return style({
prop,
themeKey: 'borders',
transform,
});
}
export const border = createBorderStyle('border', borderTransform);
export const borderTop = createBorderStyle('borderTop', borderTransform);
export const borderRight = createBorderStyle('borderRight', borderTransform);
export const borderBottom = createBorderStyle('borderBottom', borderTransform);
export const borderLeft = createBorderStyle('borderLeft', borderTransform);
export const borderColor = createBorderStyle('borderColor');
export const borderTopColor = createBorderStyle('borderTopColor');
export const borderRightColor = createBorderStyle('borderRightColor');
export const borderBottomColor = createBorderStyle('borderBottomColor');
export const borderLeftColor = createBorderStyle('borderLeftColor');
export const outline = createBorderStyle('outline', borderTransform);
export const outlineColor = createBorderStyle('outlineColor');
// false positive
// eslint-disable-next-line react/function-component-definition
export const borderRadius = (props) => {
if (props.borderRadius !== undefined && props.borderRadius !== null) {
const transformer = createUnaryUnit(props.theme, 'shape.borderRadius', 4, 'borderRadius');
const styleFromPropValue = (propValue) => ({
borderRadius: getValue(transformer, propValue),
});
return handleBreakpoints(props, props.borderRadius, styleFromPropValue);
}
return null;
};
borderRadius.propTypes =
process.env.NODE_ENV !== 'production' ? { borderRadius: responsivePropType } : {};
borderRadius.filterProps = ['borderRadius'];
const borders = compose(
border,
borderTop,
borderRight,
borderBottom,
borderLeft,
borderColor,
borderTopColor,
borderRightColor,
borderBottomColor,
borderLeftColor,
borderRadius,
outline,
outlineColor,
);
export default borders;

View File

@@ -0,0 +1,56 @@
import { expect } from 'chai';
import borders from './borders';
describe('borders', () => {
it('should work', () => {
const output = borders({
border: 1,
borderRadius: 1,
outline: 1,
});
expect(output).to.deep.equal({
border: '1px solid',
borderRadius: 4,
outline: '1px solid',
});
});
it('should work with 0', () => {
const output = borders({
borderRadius: 0,
});
expect(output).to.deep.equal({
borderRadius: 0,
});
});
describe('theme shape as string', () => {
it('should work', () => {
const output = borders({
borderRadius: 2,
theme: {
shape: {
borderRadius: '4px',
},
},
});
expect(output).to.deep.equal({
borderRadius: 'calc(2 * 4px)',
});
});
it('should work with 0', () => {
const output = borders({
borderRadius: 0,
theme: {
shape: {
borderRadius: '4px',
},
},
});
expect(output).to.deep.equal({
borderRadius: 'calc(0 * 4px)',
});
});
});
});

View File

@@ -0,0 +1,2 @@
export { default } from './borders';
export * from './borders';

View File

@@ -0,0 +1,31 @@
import { CSSObject } from '@mui/styled-engine';
import { Breakpoints } from '../createBreakpoints/createBreakpoints';
import type { Breakpoint } from '../createTheme';
import { ResponsiveStyleValue } from '../styleFunctionSx';
import { StyleFunction } from '../style';
export interface ResolveBreakpointValuesOptions<T> {
values: ResponsiveStyleValue<T>;
breakpoints?: Breakpoints['values'];
base?: Record<string, boolean>;
}
export function resolveBreakpointValues<T>(
options: ResolveBreakpointValuesOptions<T>,
): Record<string, T>;
export function mergeBreakpointsInOrder(breakpoints: Breakpoints, styles: CSSObject[]): CSSObject;
export function handleBreakpoints<Props>(
props: Props,
propValue: any,
styleFromPropValue: (value: any, breakpoint?: Breakpoint) => any,
): any;
type DefaultBreakPoints = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
/**
* @returns An enhanced stylefunction that considers breakpoints
*/
export default function breakpoints<Props, Breakpoints extends string = DefaultBreakPoints>(
styleFunction: StyleFunction<Props>,
): StyleFunction<Partial<Record<Breakpoints, Props>> & Props>;

View File

@@ -0,0 +1,200 @@
import PropTypes from 'prop-types';
import deepmerge from '@mui/utils/deepmerge';
import merge from '../merge';
import { isCqShorthand, getContainerQuery } from '../cssContainerQueries';
// The breakpoint **start** at this value.
// For instance with the first breakpoint xs: [xs, sm[.
export const values = {
xs: 0, // phone
sm: 600, // tablet
md: 900, // small laptop
lg: 1200, // desktop
xl: 1536, // large screen
};
const defaultBreakpoints = {
// Sorted ASC by size. That's important.
// It can't be configured as it's used statically for propTypes.
keys: ['xs', 'sm', 'md', 'lg', 'xl'],
up: (key) => `@media (min-width:${values[key]}px)`,
};
const defaultContainerQueries = {
containerQueries: (containerName) => ({
up: (key) => {
let result = typeof key === 'number' ? key : values[key] || key;
if (typeof result === 'number') {
result = `${result}px`;
}
return containerName
? `@container ${containerName} (min-width:${result})`
: `@container (min-width:${result})`;
},
}),
};
export function handleBreakpoints(props, propValue, styleFromPropValue) {
const theme = props.theme || {};
if (Array.isArray(propValue)) {
const themeBreakpoints = theme.breakpoints || defaultBreakpoints;
return propValue.reduce((acc, item, index) => {
acc[themeBreakpoints.up(themeBreakpoints.keys[index])] = styleFromPropValue(propValue[index]);
return acc;
}, {});
}
if (typeof propValue === 'object') {
const themeBreakpoints = theme.breakpoints || defaultBreakpoints;
return Object.keys(propValue).reduce((acc, breakpoint) => {
if (isCqShorthand(themeBreakpoints.keys, breakpoint)) {
const containerKey = getContainerQuery(
theme.containerQueries ? theme : defaultContainerQueries,
breakpoint,
);
if (containerKey) {
acc[containerKey] = styleFromPropValue(propValue[breakpoint], breakpoint);
}
}
// key is breakpoint
else if (Object.keys(themeBreakpoints.values || values).includes(breakpoint)) {
const mediaKey = themeBreakpoints.up(breakpoint);
acc[mediaKey] = styleFromPropValue(propValue[breakpoint], breakpoint);
} else {
const cssKey = breakpoint;
acc[cssKey] = propValue[cssKey];
}
return acc;
}, {});
}
const output = styleFromPropValue(propValue);
return output;
}
function breakpoints(styleFunction) {
// false positive
// eslint-disable-next-line react/function-component-definition
const newStyleFunction = (props) => {
const theme = props.theme || {};
const base = styleFunction(props);
const themeBreakpoints = theme.breakpoints || defaultBreakpoints;
const extended = themeBreakpoints.keys.reduce((acc, key) => {
if (props[key]) {
acc = acc || {};
acc[themeBreakpoints.up(key)] = styleFunction({ theme, ...props[key] });
}
return acc;
}, null);
return merge(base, extended);
};
newStyleFunction.propTypes =
process.env.NODE_ENV !== 'production'
? {
...styleFunction.propTypes,
xs: PropTypes.object,
sm: PropTypes.object,
md: PropTypes.object,
lg: PropTypes.object,
xl: PropTypes.object,
}
: {};
newStyleFunction.filterProps = ['xs', 'sm', 'md', 'lg', 'xl', ...styleFunction.filterProps];
return newStyleFunction;
}
export function createEmptyBreakpointObject(breakpointsInput = {}) {
const breakpointsInOrder = breakpointsInput.keys?.reduce((acc, key) => {
const breakpointStyleKey = breakpointsInput.up(key);
acc[breakpointStyleKey] = {};
return acc;
}, {});
return breakpointsInOrder || {};
}
export function removeUnusedBreakpoints(breakpointKeys, style) {
return breakpointKeys.reduce((acc, key) => {
const breakpointOutput = acc[key];
const isBreakpointUnused = !breakpointOutput || Object.keys(breakpointOutput).length === 0;
if (isBreakpointUnused) {
delete acc[key];
}
return acc;
}, style);
}
export function mergeBreakpointsInOrder(breakpointsInput, ...styles) {
const emptyBreakpoints = createEmptyBreakpointObject(breakpointsInput);
const mergedOutput = [emptyBreakpoints, ...styles].reduce(
(prev, next) => deepmerge(prev, next),
{},
);
return removeUnusedBreakpoints(Object.keys(emptyBreakpoints), mergedOutput);
}
// compute base for responsive values; e.g.,
// [1,2,3] => {xs: true, sm: true, md: true}
// {xs: 1, sm: 2, md: 3} => {xs: true, sm: true, md: true}
export function computeBreakpointsBase(breakpointValues, themeBreakpoints) {
// fixed value
if (typeof breakpointValues !== 'object') {
return {};
}
const base = {};
const breakpointsKeys = Object.keys(themeBreakpoints);
if (Array.isArray(breakpointValues)) {
breakpointsKeys.forEach((breakpoint, i) => {
if (i < breakpointValues.length) {
base[breakpoint] = true;
}
});
} else {
breakpointsKeys.forEach((breakpoint) => {
if (breakpointValues[breakpoint] != null) {
base[breakpoint] = true;
}
});
}
return base;
}
export function resolveBreakpointValues({
values: breakpointValues,
breakpoints: themeBreakpoints,
base: customBase,
}) {
const base = customBase || computeBreakpointsBase(breakpointValues, themeBreakpoints);
const keys = Object.keys(base);
if (keys.length === 0) {
return breakpointValues;
}
let previous;
return keys.reduce((acc, breakpoint, i) => {
if (Array.isArray(breakpointValues)) {
acc[breakpoint] =
breakpointValues[i] != null ? breakpointValues[i] : breakpointValues[previous];
previous = i;
} else if (typeof breakpointValues === 'object') {
acc[breakpoint] =
breakpointValues[breakpoint] != null
? breakpointValues[breakpoint]
: breakpointValues[previous];
previous = breakpoint;
} else {
acc[breakpoint] = breakpointValues;
}
return acc;
}, {});
}
export default breakpoints;

View File

@@ -0,0 +1,272 @@
import { expect } from 'chai';
import breakpoints, {
computeBreakpointsBase,
resolveBreakpointValues,
removeUnusedBreakpoints,
} from './breakpoints';
import style from '../style';
const textColor = style({
prop: 'color',
themeKey: 'palette',
});
describe('breakpoints', () => {
const muiThemeBreakpoints = { xs: 0, sm: 600, md: 900, lg: 1200, xl: 1536 };
const customThemeBreakpoints = {
extraSmall: 0,
small: 300,
medium: 600,
large: 900,
extraLarge: 1200,
};
it('should work', () => {
const palette = breakpoints(textColor);
expect(palette.filterProps.length).to.equal(6);
expect(
palette({
color: 'red',
sm: {
color: 'blue',
},
}),
).to.deep.equal({
color: 'red',
'@media (min-width:600px)': {
color: 'blue',
},
});
});
describe('function: computeBreakpointsBase', () => {
describe('mui default breakpoints', () => {
it('compute base for breakpoint values of array type', () => {
const columns = [1, 2, 3];
const base = computeBreakpointsBase(columns, muiThemeBreakpoints);
expect(base).to.deep.equal({ xs: true, sm: true, md: true });
});
it('compute base for breakpoint values of object type', () => {
const columns = { xs: 1, sm: 2, md: 3 };
const base = computeBreakpointsBase(columns, muiThemeBreakpoints);
expect(base).to.deep.equal({ xs: true, sm: true, md: true });
});
it('return empty object for fixed value', () => {
const columns = 3;
const base = computeBreakpointsBase(columns, muiThemeBreakpoints);
expect(base).to.deep.equal({});
});
});
describe('custom breakpoints', () => {
it('compute base for breakpoint values of array type', () => {
const columns = [1, 2, 3];
const base = computeBreakpointsBase(columns, customThemeBreakpoints);
expect(base).to.deep.equal({ extraSmall: true, small: true, medium: true });
});
it('compute base for breakpoint values of object type', () => {
const columns = { extraSmall: 1, small: 2, medium: 3 };
const base = computeBreakpointsBase(columns, customThemeBreakpoints);
expect(base).to.deep.equal({ extraSmall: true, small: true, medium: true });
});
it('return empty object for fixed value', () => {
const columns = 3;
const base = computeBreakpointsBase(columns, customThemeBreakpoints);
expect(base).to.deep.equal({});
});
});
});
describe('function: resolveBreakpointValues', () => {
describe('mui default breakpoints', () => {
it('resolve breakpoint values for prop of array type', () => {
const columns = [1, 2, 3];
const values = resolveBreakpointValues({
values: columns,
breakpoints: muiThemeBreakpoints,
});
expect(values).to.deep.equal({ xs: 1, sm: 2, md: 3 });
});
it('resolve breakpoint values for prop of object type', () => {
const columns = { xs: 1, sm: 2, md: 3 };
const values = resolveBreakpointValues({
values: columns,
breakpoints: muiThemeBreakpoints,
});
expect(values).to.deep.equal({ xs: 1, sm: 2, md: 3 });
});
it('resolve breakpoint values for unordered prop of object type', () => {
const columns = { sm: 2, md: 3, xs: 1 };
const values = resolveBreakpointValues({
values: columns,
breakpoints: muiThemeBreakpoints,
});
expect(values).to.deep.equal({ xs: 1, sm: 2, md: 3 });
});
it('return prop as it is for prop of fixed value', () => {
const columns = 3;
const values = resolveBreakpointValues({
values: columns,
breakpoints: muiThemeBreakpoints,
});
expect(values).to.equal(3);
});
it('given custom base, resolve breakpoint values for prop of array type', () => {
const columns = [1, 2, 3];
const customBase = { xs: true, sm: true, md: true, lg: true };
const values = resolveBreakpointValues({ values: columns, base: customBase });
expect(values).to.deep.equal({ xs: 1, sm: 2, md: 3, lg: 3 });
});
it('given custom base, resolve breakpoint values for prop of object type', () => {
const columns = { xs: 1, sm: 2, md: 3 };
const customBase = { xs: true, sm: true, md: true, lg: true };
const values = resolveBreakpointValues({ values: columns, base: customBase });
expect(values).to.deep.equal({ xs: 1, sm: 2, md: 3, lg: 3 });
});
it('given custom base, resolve breakpoint values for unordered prop of object type', () => {
const columns = { sm: 2, md: 3, xs: 1 };
const customBase = { xs: true, sm: true, md: true, lg: true };
const values = resolveBreakpointValues({ values: columns, base: customBase });
expect(values).to.deep.equal({ xs: 1, sm: 2, md: 3, lg: 3 });
});
it('given custom base, resolve breakpoint values for prop of object type with missing breakpoints', () => {
const columns = { xs: 1, md: 2 };
const customBase = { xs: true, sm: true, md: true, lg: true };
const values = resolveBreakpointValues({ values: columns, base: customBase });
expect(values).to.deep.equal({ xs: 1, sm: 1, md: 2, lg: 2 });
});
it('given custom base, resolve breakpoint values for unordered prop of object type with missing breakpoints', () => {
const columns = { md: 2, xs: 1 };
const customBase = { xs: true, sm: true, md: true, lg: true };
const values = resolveBreakpointValues({ values: columns, base: customBase });
expect(values).to.deep.equal({ xs: 1, sm: 1, md: 2, lg: 2 });
});
});
describe('custom breakpoints', () => {
it('resolve breakpoint values for prop of array type', () => {
const columns = [1, 2, 3];
const values = resolveBreakpointValues({
values: columns,
breakpoints: customThemeBreakpoints,
});
expect(values).to.deep.equal({ extraSmall: 1, small: 2, medium: 3 });
});
it('resolve breakpoint values for prop of object type', () => {
const columns = { extraSmall: 1, small: 2, medium: 3 };
const values = resolveBreakpointValues({
values: columns,
breakpoints: customThemeBreakpoints,
});
expect(values).to.deep.equal({ extraSmall: 1, small: 2, medium: 3 });
});
it('resolve breakpoint values for unordered prop of object type', () => {
const columns = { small: 2, medium: 3, extraSmall: 1 };
const values = resolveBreakpointValues({
values: columns,
breakpoints: customThemeBreakpoints,
});
expect(values).to.deep.equal({ extraSmall: 1, small: 2, medium: 3 });
});
it('return prop as it is for prop of fixed value', () => {
const columns = 3;
const values = resolveBreakpointValues({
values: columns,
breakpoints: customThemeBreakpoints,
});
expect(values).to.equal(3);
});
it('return prop as it is for prop of fixed string value', () => {
const directionValue = 'columns';
const values = resolveBreakpointValues({
values: directionValue,
});
expect(values).to.equal('columns');
});
it('given custom base, resolve breakpoint values for prop of string type', () => {
const directionValue = 'columns';
const values = resolveBreakpointValues({
values: directionValue,
base: { small: true },
});
expect(values).to.deep.equal({ small: directionValue });
});
it('given custom base, resolve breakpoint values for prop of number type', () => {
const spacingValue = 3;
const values = resolveBreakpointValues({
values: spacingValue,
base: { small: true },
});
expect(values).to.deep.equal({ small: spacingValue });
});
it('given custom base, resolve breakpoint values for prop of array type', () => {
const columns = [1, 2, 3];
const customBase = { extraSmall: true, small: true, medium: true, large: true };
const values = resolveBreakpointValues({ values: columns, base: customBase });
expect(values).to.deep.equal({ extraSmall: 1, small: 2, medium: 3, large: 3 });
});
it('given custom base, resolve breakpoint values for prop of object type', () => {
const columns = { extraSmall: 1, small: 2, medium: 3 };
const customBase = { extraSmall: true, small: true, medium: true, large: true };
const values = resolveBreakpointValues({ values: columns, base: customBase });
expect(values).to.deep.equal({ extraSmall: 1, small: 2, medium: 3, large: 3 });
});
it('given custom base, resolve breakpoint values for unordered prop of object type', () => {
const columns = { small: 2, medium: 3, extraSmall: 1 };
const customBase = { extraSmall: true, small: true, medium: true, large: true };
const values = resolveBreakpointValues({ values: columns, base: customBase });
expect(values).to.deep.equal({ extraSmall: 1, small: 2, medium: 3, large: 3 });
});
});
});
describe('function: removeUnusedBreakpoints', () => {
it('allow value to be null', () => {
const result = removeUnusedBreakpoints(
['@media (min-width:0px)', '@media (min-width:600px)', '@media (min-width:960px)'],
{
'@media (min-width:0px)': {
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
fontSize: '0.875rem',
letterSpacing: '0.01071em',
fontWeight: 400,
lineHeight: 1.43,
},
'@media (min-width:600px)': null,
'@media (min-width:960px)': {},
},
);
expect(result).to.deep.equal({
'@media (min-width:0px)': {
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
fontSize: '0.875rem',
letterSpacing: '0.01071em',
fontWeight: 400,
lineHeight: 1.43,
},
});
});
});
});

View File

@@ -0,0 +1,2 @@
export { default } from './breakpoints';
export * from './breakpoints';

View File

@@ -0,0 +1,30 @@
/* eslint-disable @typescript-eslint/naming-convention */
export type ColorFormat = 'rgb' | 'rgba' | 'hsl' | 'hsla' | 'color';
export interface ColorObject {
type: ColorFormat;
values: [number, number, number] | [number, number, number, number];
colorSpace?: 'srgb' | 'display-p3' | 'a98-rgb' | 'prophoto-rgb' | 'rec-2020';
}
export function hexToRgb(hex: string): string;
export function rgbToHex(color: string): string;
export function hslToRgb(color: string): string;
export function decomposeColor(color: string): ColorObject;
export function colorChannel(color: string): string;
export function private_safeColorChannel(color: string, warning?: string): string;
export function recomposeColor(color: ColorObject): string;
export function getContrastRatio(foreground: string, background: string): number;
export function getLuminance(color: string): number;
export function emphasize(color: string, coefficient?: number): string;
export function private_safeEmphasize(
color: string,
coefficient?: number,
warning?: string,
): string;
export function alpha(color: string, value: number): string;
export function private_safeAlpha(color: string, value: number, warning?: string): string;
export function darken(color: string, coefficient: number): string;
export function private_safeDarken(color: string, coefficient: number, warning?: string): string;
export function lighten(color: string, coefficient: number): string;
export function private_safeLighten(color: string, coefficient: number, warning?: string): string;
export function blend(background: string, overlay: string, opacity: number, gamma?: number): string;

View File

@@ -0,0 +1,382 @@
/* eslint-disable @typescript-eslint/naming-convention */
import clamp from '@mui/utils/clamp';
/**
* Returns a number whose value is limited to the given range.
* @param {number} value The value to be clamped
* @param {number} min The lower boundary of the output range
* @param {number} max The upper boundary of the output range
* @returns {number} A number in the range [min, max]
*/
function clampWrapper(value, min = 0, max = 1) {
if (process.env.NODE_ENV !== 'production') {
if (value < min || value > max) {
console.error(`MUI: The value provided ${value} is out of range [${min}, ${max}].`);
}
}
return clamp(value, min, max);
}
/**
* Converts a color from CSS hex format to CSS rgb format.
* @param {string} color - Hex color, i.e. #nnn or #nnnnnn
* @returns {string} A CSS rgb color string
*/
export function hexToRgb(color) {
color = color.slice(1);
const re = new RegExp(`.{1,${color.length >= 6 ? 2 : 1}}`, 'g');
let colors = color.match(re);
if (colors && colors[0].length === 1) {
colors = colors.map((n) => n + n);
}
if (process.env.NODE_ENV !== 'production') {
if (color.length !== color.trim().length) {
console.error(
`MUI: The color: "${color}" is invalid. Make sure the color input doesn't contain leading/trailing space.`,
);
}
}
return colors
? `rgb${colors.length === 4 ? 'a' : ''}(${colors
.map((n, index) => {
return index < 3 ? parseInt(n, 16) : Math.round((parseInt(n, 16) / 255) * 1000) / 1000;
})
.join(', ')})`
: '';
}
function intToHex(int) {
const hex = int.toString(16);
return hex.length === 1 ? `0${hex}` : hex;
}
/**
* Returns an object with the type and values of a color.
*
* Note: Does not support rgb % values.
* @param {string} color - CSS color, i.e. one of: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla(), color()
* @returns {object} - A MUI color object: {type: string, values: number[]}
*/
export function decomposeColor(color) {
// Idempotent
if (color.type) {
return color;
}
if (color.charAt(0) === '#') {
return decomposeColor(hexToRgb(color));
}
const marker = color.indexOf('(');
const type = color.substring(0, marker);
if (!['rgb', 'rgba', 'hsl', 'hsla', 'color'].includes(type)) {
throw /* minify-error */ new Error(
`MUI: Unsupported \`${color}\` color.\n` +
'The following formats are supported: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla(), color().',
);
}
let values = color.substring(marker + 1, color.length - 1);
let colorSpace;
if (type === 'color') {
values = values.split(' ');
colorSpace = values.shift();
if (values.length === 4 && values[3].charAt(0) === '/') {
values[3] = values[3].slice(1);
}
if (!['srgb', 'display-p3', 'a98-rgb', 'prophoto-rgb', 'rec-2020'].includes(colorSpace)) {
throw /* minify-error */ new Error(
`MUI: unsupported \`${colorSpace}\` color space.\n` +
'The following color spaces are supported: srgb, display-p3, a98-rgb, prophoto-rgb, rec-2020.',
);
}
} else {
values = values.split(',');
}
values = values.map((value) => parseFloat(value));
return { type, values, colorSpace };
}
/**
* Returns a channel created from the input color.
*
* @param {string} color - CSS color, i.e. one of: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla(), color()
* @returns {string} - The channel for the color, that can be used in rgba or hsla colors
*/
export const colorChannel = (color) => {
const decomposedColor = decomposeColor(color);
return decomposedColor.values
.slice(0, 3)
.map((val, idx) => (decomposedColor.type.includes('hsl') && idx !== 0 ? `${val}%` : val))
.join(' ');
};
export const private_safeColorChannel = (color, warning) => {
try {
return colorChannel(color);
} catch (error) {
if (warning && process.env.NODE_ENV !== 'production') {
console.warn(warning);
}
return color;
}
};
/**
* Converts a color object with type and values to a string.
* @param {object} color - Decomposed color
* @param {string} color.type - One of: 'rgb', 'rgba', 'hsl', 'hsla', 'color'
* @param {array} color.values - [n,n,n] or [n,n,n,n]
* @returns {string} A CSS color string
*/
export function recomposeColor(color) {
const { type, colorSpace } = color;
let { values } = color;
if (type.includes('rgb')) {
// Only convert the first 3 values to int (i.e. not alpha)
values = values.map((n, i) => (i < 3 ? parseInt(n, 10) : n));
} else if (type.includes('hsl')) {
values[1] = `${values[1]}%`;
values[2] = `${values[2]}%`;
}
if (type.includes('color')) {
values = `${colorSpace} ${values.join(' ')}`;
} else {
values = `${values.join(', ')}`;
}
return `${type}(${values})`;
}
/**
* Converts a color from CSS rgb format to CSS hex format.
* @param {string} color - RGB color, i.e. rgb(n, n, n)
* @returns {string} A CSS rgb color string, i.e. #nnnnnn
*/
export function rgbToHex(color) {
// Idempotent
if (color.startsWith('#')) {
return color;
}
const { values } = decomposeColor(color);
return `#${values.map((n, i) => intToHex(i === 3 ? Math.round(255 * n) : n)).join('')}`;
}
/**
* Converts a color from hsl format to rgb format.
* @param {string} color - HSL color values
* @returns {string} rgb color values
*/
export function hslToRgb(color) {
color = decomposeColor(color);
const { values } = color;
const h = values[0];
const s = values[1] / 100;
const l = values[2] / 100;
const a = s * Math.min(l, 1 - l);
const f = (n, k = (n + h / 30) % 12) => l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
let type = 'rgb';
const rgb = [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
if (color.type === 'hsla') {
type += 'a';
rgb.push(values[3]);
}
return recomposeColor({ type, values: rgb });
}
/**
* The relative brightness of any point in a color space,
* normalized to 0 for darkest black and 1 for lightest white.
*
* Formula: https://www.w3.org/TR/WCAG20-TECHS/G17.html#G17-tests
* @param {string} color - CSS color, i.e. one of: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla(), color()
* @returns {number} The relative brightness of the color in the range 0 - 1
*/
export function getLuminance(color) {
color = decomposeColor(color);
let rgb =
color.type === 'hsl' || color.type === 'hsla'
? decomposeColor(hslToRgb(color)).values
: color.values;
rgb = rgb.map((val) => {
if (color.type !== 'color') {
val /= 255; // normalized
}
return val <= 0.03928 ? val / 12.92 : ((val + 0.055) / 1.055) ** 2.4;
});
// Truncate at 3 digits
return Number((0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]).toFixed(3));
}
/**
* Calculates the contrast ratio between two colors.
*
* Formula: https://www.w3.org/TR/WCAG20-TECHS/G17.html#G17-tests
* @param {string} foreground - CSS color, i.e. one of: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla()
* @param {string} background - CSS color, i.e. one of: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla()
* @returns {number} A contrast ratio value in the range 0 - 21.
*/
export function getContrastRatio(foreground, background) {
const lumA = getLuminance(foreground);
const lumB = getLuminance(background);
return (Math.max(lumA, lumB) + 0.05) / (Math.min(lumA, lumB) + 0.05);
}
/**
* Sets the absolute transparency of a color.
* Any existing alpha values are overwritten.
* @param {string} color - CSS color, i.e. one of: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla(), color()
* @param {number} value - value to set the alpha channel to in the range 0 - 1
* @returns {string} A CSS color string. Hex input values are returned as rgb
*/
export function alpha(color, value) {
color = decomposeColor(color);
value = clampWrapper(value);
if (color.type === 'rgb' || color.type === 'hsl') {
color.type += 'a';
}
if (color.type === 'color') {
color.values[3] = `/${value}`;
} else {
color.values[3] = value;
}
return recomposeColor(color);
}
export function private_safeAlpha(color, value, warning) {
try {
return alpha(color, value);
} catch (error) {
if (warning && process.env.NODE_ENV !== 'production') {
console.warn(warning);
}
return color;
}
}
/**
* Darkens a color.
* @param {string} color - CSS color, i.e. one of: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla(), color()
* @param {number} coefficient - multiplier in the range 0 - 1
* @returns {string} A CSS color string. Hex input values are returned as rgb
*/
export function darken(color, coefficient) {
color = decomposeColor(color);
coefficient = clampWrapper(coefficient);
if (color.type.includes('hsl')) {
color.values[2] *= 1 - coefficient;
} else if (color.type.includes('rgb') || color.type.includes('color')) {
for (let i = 0; i < 3; i += 1) {
color.values[i] *= 1 - coefficient;
}
}
return recomposeColor(color);
}
export function private_safeDarken(color, coefficient, warning) {
try {
return darken(color, coefficient);
} catch (error) {
if (warning && process.env.NODE_ENV !== 'production') {
console.warn(warning);
}
return color;
}
}
/**
* Lightens a color.
* @param {string} color - CSS color, i.e. one of: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla(), color()
* @param {number} coefficient - multiplier in the range 0 - 1
* @returns {string} A CSS color string. Hex input values are returned as rgb
*/
export function lighten(color, coefficient) {
color = decomposeColor(color);
coefficient = clampWrapper(coefficient);
if (color.type.includes('hsl')) {
color.values[2] += (100 - color.values[2]) * coefficient;
} else if (color.type.includes('rgb')) {
for (let i = 0; i < 3; i += 1) {
color.values[i] += (255 - color.values[i]) * coefficient;
}
} else if (color.type.includes('color')) {
for (let i = 0; i < 3; i += 1) {
color.values[i] += (1 - color.values[i]) * coefficient;
}
}
return recomposeColor(color);
}
export function private_safeLighten(color, coefficient, warning) {
try {
return lighten(color, coefficient);
} catch (error) {
if (warning && process.env.NODE_ENV !== 'production') {
console.warn(warning);
}
return color;
}
}
/**
* Darken or lighten a color, depending on its luminance.
* Light colors are darkened, dark colors are lightened.
* @param {string} color - CSS color, i.e. one of: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla(), color()
* @param {number} coefficient=0.15 - multiplier in the range 0 - 1
* @returns {string} A CSS color string. Hex input values are returned as rgb
*/
export function emphasize(color, coefficient = 0.15) {
return getLuminance(color) > 0.5 ? darken(color, coefficient) : lighten(color, coefficient);
}
export function private_safeEmphasize(color, coefficient, warning) {
try {
return emphasize(color, coefficient);
} catch (error) {
if (warning && process.env.NODE_ENV !== 'production') {
console.warn(warning);
}
return color;
}
}
/**
* Blend a transparent overlay color with a background color, resulting in a single
* RGB color.
* @param {string} background - CSS color
* @param {string} overlay - CSS color
* @param {number} opacity - Opacity multiplier in the range 0 - 1
* @param {number} [gamma=1.0] - Gamma correction factor. For gamma-correct blending, 2.2 is usual.
*/
export function blend(background, overlay, opacity, gamma = 1.0) {
const blendChannel = (b, o) =>
Math.round((b ** (1 / gamma) * (1 - opacity) + o ** (1 / gamma) * opacity) ** gamma);
const backgroundColor = decomposeColor(background);
const overlayColor = decomposeColor(overlay);
const rgb = [
blendChannel(backgroundColor.values[0], overlayColor.values[0]),
blendChannel(backgroundColor.values[1], overlayColor.values[1]),
blendChannel(backgroundColor.values[2], overlayColor.values[2]),
];
return recomposeColor({
type: 'rgb',
values: rgb,
});
}

View File

@@ -0,0 +1,56 @@
import { expectType } from '@mui/types';
import { ColorFormat, ColorObject } from '@mui/system';
import {
hexToRgb,
rgbToHex,
hslToRgb,
decomposeColor,
colorChannel,
recomposeColor,
getContrastRatio,
getLuminance,
emphasize,
alpha,
lighten,
darken,
} from '@mui/system/colorManipulator';
expectType<(color: string) => string, typeof hexToRgb>(hexToRgb);
expectType<(color: string) => string, typeof rgbToHex>(rgbToHex);
expectType<(color: string) => string, typeof hslToRgb>(hslToRgb);
expectType<(color: string) => ColorObject, typeof decomposeColor>(decomposeColor);
expectType<(color: string) => string, typeof colorChannel>(colorChannel);
expectType<(color: ColorObject) => string, typeof recomposeColor>(recomposeColor);
expectType<(foreground: string, background: string) => number, typeof getContrastRatio>(
getContrastRatio,
);
expectType<(color: string) => number, typeof getLuminance>(getLuminance);
expectType<(color: string, coefficient?: number) => string, typeof emphasize>(emphasize);
expectType<(color: string, value: number) => string, typeof alpha>(alpha);
expectType<(color: string, coefficient: number) => string, typeof darken>(darken);
expectType<(color: string, coefficient: number) => string, typeof lighten>(lighten);
recomposeColor({
type: 'color',
colorSpace: 'display-p3',
values: [0.5, 0.3, 0.2],
});
const color = decomposeColor('color(display-p3 0 1 0)');
type Color = 'color' extends typeof color.type ? true : false;
expectType<Color, true>(true);
expectType<ColorFormat, typeof color.type>(color.type);

View File

@@ -0,0 +1,485 @@
import { expect } from 'chai';
import { blend } from '@mui/system';
import {
recomposeColor,
hexToRgb,
rgbToHex,
hslToRgb,
darken,
decomposeColor,
emphasize,
alpha,
getContrastRatio,
getLuminance,
lighten,
colorChannel,
} from '@mui/system/colorManipulator';
describe('utils/colorManipulator', () => {
describe('recomposeColor', () => {
it('converts a decomposed rgb color object to a string`', () => {
expect(
recomposeColor({
type: 'rgb',
values: [255, 255, 255],
}),
).to.equal('rgb(255, 255, 255)');
});
it('converts a decomposed rgba color object to a string`', () => {
expect(
recomposeColor({
type: 'rgba',
values: [255, 255, 255, 0.5],
}),
).to.equal('rgba(255, 255, 255, 0.5)');
});
it('converts a decomposed CSS4 color object to a string`', () => {
expect(
recomposeColor({
type: 'color',
colorSpace: 'display-p3',
values: [0.5, 0.3, 0.2],
}),
).to.equal('color(display-p3 0.5 0.3 0.2)');
});
it('converts a decomposed hsl color object to a string`', () => {
expect(
recomposeColor({
type: 'hsl',
values: [100, 50, 25],
}),
).to.equal('hsl(100, 50%, 25%)');
});
it('converts a decomposed hsla color object to a string`', () => {
expect(
recomposeColor({
type: 'hsla',
values: [100, 50, 25, 0.5],
}),
).to.equal('hsla(100, 50%, 25%, 0.5)');
});
});
describe('hexToRgb', () => {
it('converts a short hex color to an rgb color`', () => {
expect(hexToRgb('#9f3')).to.equal('rgb(153, 255, 51)');
});
it('converts a long hex color to an rgb color`', () => {
expect(hexToRgb('#a94fd3')).to.equal('rgb(169, 79, 211)');
});
it('converts a long alpha hex color to an argb color`', () => {
expect(hexToRgb('#111111f8')).to.equal('rgba(17, 17, 17, 0.973)');
});
});
describe('rgbToHex', () => {
it('converts an rgb color to a hex color`', () => {
expect(rgbToHex('rgb(169, 79, 211)')).to.equal('#a94fd3');
});
it('converts an rgba color to a hex color`', () => {
expect(rgbToHex('rgba(169, 79, 211, 1)')).to.equal('#a94fd3ff');
});
it('idempotent', () => {
expect(rgbToHex('#A94FD3')).to.equal('#A94FD3');
});
});
describe('hslToRgb', () => {
it('converts an hsl color to an rgb color`', () => {
expect(hslToRgb('hsl(281, 60%, 57%)')).to.equal('rgb(169, 80, 211)');
});
it('converts an hsla color to an rgba color`', () => {
expect(hslToRgb('hsla(281, 60%, 57%, 0.5)')).to.equal('rgba(169, 80, 211, 0.5)');
});
it('allow to convert values only', () => {
expect(hslToRgb(decomposeColor('hsl(281, 60%, 57%)'))).to.equal('rgb(169, 80, 211)');
});
});
describe('decomposeColor', () => {
it('converts an rgb color string to an object with `type` and `value` keys', () => {
const { type, values } = decomposeColor('rgb(255, 255, 255)');
expect(type).to.equal('rgb');
expect(values).to.deep.equal([255, 255, 255]);
});
it('converts an rgba color string to an object with `type` and `value` keys', () => {
const { type, values } = decomposeColor('rgba(255, 255, 255, 0.5)');
expect(type).to.equal('rgba');
expect(values).to.deep.equal([255, 255, 255, 0.5]);
});
it('converts an hsl color string to an object with `type` and `value` keys', () => {
const { type, values } = decomposeColor('hsl(100, 50%, 25%)');
expect(type).to.equal('hsl');
expect(values).to.deep.equal([100, 50, 25]);
});
it('converts an hsla color string to an object with `type` and `value` keys', () => {
const { type, values } = decomposeColor('hsla(100, 50%, 25%, 0.5)');
expect(type).to.equal('hsla');
expect(values).to.deep.equal([100, 50, 25, 0.5]);
});
it('converts CSS4 color with color space display-3', () => {
const { type, values, colorSpace } = decomposeColor('color(display-p3 0 1 0)');
expect(type).to.equal('color');
expect(colorSpace).to.equal('display-p3');
expect(values).to.deep.equal([0, 1, 0]);
});
it('converts an alpha CSS4 color with color space display-3', () => {
const { type, values, colorSpace } = decomposeColor('color(display-p3 0 1 0 /0.4)');
expect(type).to.equal('color');
expect(colorSpace).to.equal('display-p3');
expect(values).to.deep.equal([0, 1, 0, 0.4]);
});
it('should throw error with inexistent color color space', () => {
const decomposeWithError = () => decomposeColor('color(foo 0 1 0)');
expect(decomposeWithError).to.throw();
});
it('idempotent', () => {
const output1 = decomposeColor('hsla(100, 50%, 25%, 0.5)');
const output2 = decomposeColor(output1);
expect(output1).to.deep.equal(output2);
});
it('converts rgba hex', () => {
const decomposed = decomposeColor('#111111f8');
expect(decomposed).to.deep.equal({
type: 'rgba',
colorSpace: undefined,
values: [17, 17, 17, 0.973],
});
});
});
describe('getContrastRatio', () => {
it('returns a ratio for black : white', () => {
expect(getContrastRatio('#000', '#FFF')).to.equal(21);
});
it('returns a ratio for black : black', () => {
expect(getContrastRatio('#000', '#000')).to.equal(1);
});
it('returns a ratio for white : white', () => {
expect(getContrastRatio('#FFF', '#FFF')).to.equal(1);
});
it('returns a ratio for dark-grey : light-grey', () => {
expect(getContrastRatio('#707070', '#E5E5E5')).to.be.approximately(3.93, 0.01);
});
it('returns a ratio for black : light-grey', () => {
expect(getContrastRatio('#000', '#888')).to.be.approximately(5.92, 0.01);
});
});
describe('getLuminance', () => {
it('returns a valid luminance for rgb black', () => {
expect(getLuminance('rgba(0, 0, 0)')).to.equal(0);
expect(getLuminance('rgb(0, 0, 0)')).to.equal(0);
expect(getLuminance('color(display-p3 0 0 0)')).to.equal(0);
});
it('returns a valid luminance for rgb white', () => {
expect(getLuminance('rgba(255, 255, 255)')).to.equal(1);
expect(getLuminance('rgb(255, 255, 255)')).to.equal(1);
});
it('returns a valid luminance for hsla black', () => {
expect(getLuminance('hsla(0, 100%, 0%, 1)')).to.equal(0);
});
it('returns a valid luminance for hsla white', () => {
expect(getLuminance('hsla(0, 100%, 100%, 1)')).to.equal(1);
});
it('returns a valid luminance for rgb mid-grey', () => {
expect(getLuminance('rgba(127, 127, 127)')).to.equal(0.212);
expect(getLuminance('rgb(127, 127, 127)')).to.equal(0.212);
});
it('returns a valid luminance for an rgb color', () => {
expect(getLuminance('rgb(255, 127, 0)')).to.equal(0.364);
});
it('returns a valid luminance from an hsl color', () => {
expect(getLuminance('hsl(100, 100%, 50%)')).to.equal(0.735);
});
it('returns a valid luminance from an hsla color', () => {
expect(getLuminance('hsla(100, 100%, 50%, 1)')).to.equal(0.735);
});
it('returns an equal luminance for the same color in different formats', () => {
const hsl = 'hsl(100, 100%, 50%)';
const rgb = 'rgb(85, 255, 0)';
expect(getLuminance(hsl)).to.equal(getLuminance(rgb));
});
it('returns a valid luminance from an CSS4 color', () => {
expect(getLuminance('color(display-p3 1 1 0.1)')).to.equal(0.929);
});
it('throw on invalid colors', () => {
expect(() => {
getLuminance('black');
}).toThrowMinified('MUI: Unsupported `black` color');
});
});
describe('emphasize', () => {
it('lightens a dark rgb color with the coefficient provided', () => {
expect(emphasize('rgb(1, 2, 3)', 0.4)).to.equal(lighten('rgb(1, 2, 3)', 0.4));
});
it('darkens a light rgb color with the coefficient provided', () => {
expect(emphasize('rgb(250, 240, 230)', 0.3)).to.equal(darken('rgb(250, 240, 230)', 0.3));
});
it('lightens a dark rgb color with the coefficient 0.15 by default', () => {
expect(emphasize('rgb(1, 2, 3)')).to.equal(lighten('rgb(1, 2, 3)', 0.15));
});
it('darkens a light rgb color with the coefficient 0.15 by default', () => {
expect(emphasize('rgb(250, 240, 230)')).to.equal(darken('rgb(250, 240, 230)', 0.15));
});
it('lightens a dark CSS4 color with the coefficient 0.15 by default', () => {
expect(emphasize('color(display-p3 0.1 0.1 0.1)')).to.equal(
lighten('color(display-p3 0.1 0.1 0.1)', 0.15),
);
});
it('darkens a light CSS4 color with the coefficient 0.15 by default', () => {
expect(emphasize('color(display-p3 1 1 0.1)')).to.equal(
darken('color(display-p3 1 1 0.1)', 0.15),
);
});
});
describe('alpha', () => {
it('converts an rgb color to an rgba color with the value provided', () => {
expect(alpha('rgb(1, 2, 3)', 0.4)).to.equal('rgba(1, 2, 3, 0.4)');
});
it('updates an CSS4 color with the alpha value provided', () => {
expect(alpha('color(display-p3 1 2 3)', 0.4)).to.equal('color(display-p3 1 2 3 /0.4)');
});
it('updates an rgba color with the alpha value provided', () => {
expect(alpha('rgba(255, 0, 0, 0.2)', 0.5)).to.equal('rgba(255, 0, 0, 0.5)');
});
it('converts an hsl color to an hsla color with the value provided', () => {
expect(alpha('hsl(0, 100%, 50%)', 0.1)).to.equal('hsla(0, 100%, 50%, 0.1)');
});
it('updates an hsla color with the alpha value provided', () => {
expect(alpha('hsla(0, 100%, 50%, 0.2)', 0.5)).to.equal('hsla(0, 100%, 50%, 0.5)');
});
it('throw on invalid colors', () => {
expect(() => {
alpha('white', 0.4);
}).toThrowMinified('MUI: Unsupported `white` color');
});
it('warns if the color contains space at the end', () => {
let result;
expect(() => {
result = alpha('#aa0099 ', 0.5);
}).toErrorDev([
'MUI: The color: "aa0099 " is invalid. Make sure the color input doesn\'t contain leading/trailing space.',
]);
expect(result).to.equal('rgba(170, 0, 153, 0.5)');
});
});
describe('darken', () => {
it("doesn't modify rgb black", () => {
expect(darken('rgb(0, 0, 0)', 0.1)).to.equal('rgb(0, 0, 0)');
});
it("doesn't overshoot if an above-range coefficient is supplied", () => {
expect(() => {
expect(darken('rgb(0, 127, 255)', 1.5)).to.equal('rgb(0, 0, 0)');
}).toErrorDev('MUI: The value provided 1.5 is out of range [0, 1].');
});
it("doesn't overshoot if a below-range coefficient is supplied", () => {
expect(() => {
expect(darken('rgb(0, 127, 255)', -0.1)).to.equal('rgb(0, 127, 255)');
}).toErrorDev('MUI: The value provided -0.1 is out of range [0, 1].');
});
it('darkens rgb white to black when coefficient is 1', () => {
expect(darken('rgb(255, 255, 255)', 1)).to.equal('rgb(0, 0, 0)');
});
it('retains the alpha value in an rgba color', () => {
expect(darken('rgb(0, 0, 0, 0.5)', 0.1)).to.equal('rgb(0, 0, 0, 0.5)');
});
it('darkens rgb white by 10% when coefficient is 0.1', () => {
expect(darken('rgb(255, 255, 255)', 0.1)).to.equal('rgb(229, 229, 229)');
});
it('darkens rgb red by 50% when coefficient is 0.5', () => {
expect(darken('rgb(255, 0, 0)', 0.5)).to.equal('rgb(127, 0, 0)');
});
it('darkens rgb grey by 50% when coefficient is 0.5', () => {
expect(darken('rgb(127, 127, 127)', 0.5)).to.equal('rgb(63, 63, 63)');
});
it("doesn't modify rgb colors when coefficient is 0", () => {
expect(darken('rgb(255, 255, 255)', 0)).to.equal('rgb(255, 255, 255)');
});
it('darkens hsl red by 50% when coefficient is 0.5', () => {
expect(darken('hsl(0, 100%, 50%)', 0.5)).to.equal('hsl(0, 100%, 25%)');
});
it("doesn't modify hsl colors when coefficient is 0", () => {
expect(darken('hsl(0, 100%, 50%)', 0)).to.equal('hsl(0, 100%, 50%)');
});
it("doesn't modify hsl colors when l is 0%", () => {
expect(darken('hsl(0, 50%, 0%)', 0.5)).to.equal('hsl(0, 50%, 0%)');
});
it('darkens CSS4 color red by 50% when coefficient is 0.5', () => {
expect(darken('color(display-p3 1 0 0)', 0.5)).to.equal('color(display-p3 0.5 0 0)');
});
it("doesn't modify CSS4 color when coefficient is 0", () => {
expect(darken('color(display-p3 1 0 0)', 0)).to.equal('color(display-p3 1 0 0)');
});
});
describe('lighten', () => {
it("doesn't modify rgb white", () => {
expect(lighten('rgb(255, 255, 255)', 0.1)).to.equal('rgb(255, 255, 255)');
});
it("doesn't overshoot if an above-range coefficient is supplied", () => {
expect(() => {
expect(lighten('rgb(0, 127, 255)', 1.5)).to.equal('rgb(255, 255, 255)');
}).toErrorDev('MUI: The value provided 1.5 is out of range [0, 1].');
});
it("doesn't overshoot if a below-range coefficient is supplied", () => {
expect(() => {
expect(lighten('rgb(0, 127, 255)', -0.1)).to.equal('rgb(0, 127, 255)');
}).toErrorDev('MUI: The value provided -0.1 is out of range [0, 1].');
});
it('lightens rgb black to white when coefficient is 1', () => {
expect(lighten('rgb(0, 0, 0)', 1)).to.equal('rgb(255, 255, 255)');
});
it('retains the alpha value in an rgba color', () => {
expect(lighten('rgb(255, 255, 255, 0.5)', 0.1)).to.equal('rgb(255, 255, 255, 0.5)');
});
it('lightens rgb black by 10% when coefficient is 0.1', () => {
expect(lighten('rgb(0, 0, 0)', 0.1)).to.equal('rgb(25, 25, 25)');
});
it('lightens rgb red by 50% when coefficient is 0.5', () => {
expect(lighten('rgb(255, 0, 0)', 0.5)).to.equal('rgb(255, 127, 127)');
});
it('lightens rgb grey by 50% when coefficient is 0.5', () => {
expect(lighten('rgb(127, 127, 127)', 0.5)).to.equal('rgb(191, 191, 191)');
});
it("doesn't modify rgb colors when coefficient is 0", () => {
expect(lighten('rgb(127, 127, 127)', 0)).to.equal('rgb(127, 127, 127)');
});
it('lightens hsl red by 50% when coefficient is 0.5', () => {
expect(lighten('hsl(0, 100%, 50%)', 0.5)).to.equal('hsl(0, 100%, 75%)');
});
it("doesn't modify hsl colors when coefficient is 0", () => {
expect(lighten('hsl(0, 100%, 50%)', 0)).to.equal('hsl(0, 100%, 50%)');
});
it("doesn't modify hsl colors when `l` is 100%", () => {
expect(lighten('hsl(0, 50%, 100%)', 0.5)).to.equal('hsl(0, 50%, 100%)');
});
it('lightens CSS4 color red by 50% when coefficient is 0.5', () => {
expect(lighten('color(display-p3 1 0 0)', 0.5)).to.equal('color(display-p3 1 0.5 0.5)');
});
it("doesn't modify CSS4 color when coefficient is 0", () => {
expect(lighten('color(display-p3 1 0 0)', 0)).to.equal('color(display-p3 1 0 0)');
});
});
describe('colorChannel', () => {
it('converts a short hex color to a color channel`', () => {
expect(colorChannel('#9f3')).to.equal('153 255 51');
});
it('converts a long hex color to a colorChannel`', () => {
expect(colorChannel('#a94fd3')).to.equal('169 79 211');
});
it('converts a long alpha hex color to a color channel`', () => {
expect(colorChannel('#111111f8')).to.equal('17 17 17');
});
it('converts rgb to a color channel`', () => {
expect(colorChannel('rgb(169, 79, 211)')).to.equal('169 79 211');
});
it('converts rgba to a color channel`', () => {
expect(colorChannel('rgba(255, 11, 13, 0.5)')).to.equal('255 11 13');
});
it('converts hsl to a color channel`', () => {
expect(colorChannel('hsl(170, 45%, 50%)')).to.equal('170 45% 50%');
});
it('converts hsla to a color channel`', () => {
expect(colorChannel('hsla(235, 100%, 50%, .5)')).to.equal('235 100% 50%');
});
});
describe('blend', () => {
it('works', () => {
expect(blend('rgb(90, 90, 90)', 'rgb(10, 100, 255)', 0.5)).to.equal('rgb(50, 95, 173)');
});
it('works with a gamma correction factor', () => {
expect(blend('rgb(90, 90, 90)', 'rgb(10, 100, 255)', 0.5, 2.2)).to.equal('rgb(39, 95, 161)');
});
it('selects only the background color with an opacity of 0.0', () => {
expect(blend('rgb(90, 90, 90)', 'rgb(10, 100, 255)', 0.0)).to.equal('rgb(90, 90, 90)');
});
it('selects only the overlay color with an opacity of 1.0', () => {
expect(blend('rgb(90, 90, 90)', 'rgb(10, 100, 255)', 1.0)).to.equal('rgb(10, 100, 255)');
});
});
});

View File

@@ -0,0 +1 @@
export * from './colorManipulator';

View File

@@ -0,0 +1,19 @@
import { StyleFunction } from '../style';
/**
* given a list of StyleFunction return the intersection of the props each individual
* StyleFunction requires.
*
* If `firstFn` requires { color: string } and `secondFn` requires { spacing: number }
* their composed function requires { color: string, spacing: number }
*/
type ComposedArg<T> = T extends Array<(arg: infer P) => any> ? P : never;
type ComposedOwnerState<T> = ComposedArg<T>;
export type ComposedStyleFunction<T extends Array<StyleFunction<any>>> = StyleFunction<
ComposedOwnerState<T>
> & { filterProps: string[] };
export default function compose<T extends Array<StyleFunction<any>>>(
...args: T
): ComposedStyleFunction<T>;

View File

@@ -0,0 +1,34 @@
import merge from '../merge';
function compose(...styles) {
const handlers = styles.reduce((acc, style) => {
style.filterProps.forEach((prop) => {
acc[prop] = style;
});
return acc;
}, {});
// false positive
// eslint-disable-next-line react/function-component-definition
const fn = (props) => {
return Object.keys(props).reduce((acc, prop) => {
if (handlers[prop]) {
return merge(acc, handlers[prop](props));
}
return acc;
}, {});
};
fn.propTypes =
process.env.NODE_ENV !== 'production'
? styles.reduce((acc, style) => Object.assign(acc, style.propTypes), {})
: {};
fn.filterProps = styles.reduce((acc, style) => acc.concat(style.filterProps), []);
return fn;
}
export default compose;

View File

@@ -0,0 +1,31 @@
import { expect } from 'chai';
import compose from './compose';
import style from '../style';
const textColor = style({
prop: 'color',
themeKey: 'palette',
});
const bgcolor = style({
prop: 'bgcolor',
cssProperty: 'backgroundColor',
themeKey: 'palette',
});
describe('compose', () => {
it('should compose', () => {
const palette = compose(textColor, bgcolor);
expect(palette.filterProps.length).to.equal(2);
expect(
palette({
color: 'red',
bgcolor: 'gree',
}),
).to.deep.equal({
backgroundColor: 'gree',
color: 'red',
});
});
});

View File

@@ -0,0 +1 @@
export { default, type ComposedStyleFunction } from './compose';

View File

@@ -0,0 +1,13 @@
import { OverridableComponent } from '@mui/types';
import { BoxTypeMap } from '../Box';
import { Theme as SystemTheme } from '../createTheme';
export default function createBox<
T extends object = SystemTheme,
AdditionalProps extends Record<string, unknown> = {},
>(options?: {
themeId?: string;
defaultTheme: T;
defaultClassName?: string;
generateClassName?: (componentName: string) => string;
}): OverridableComponent<BoxTypeMap<AdditionalProps, 'div', T>>;

View File

@@ -0,0 +1,33 @@
'use client';
import * as React from 'react';
import clsx from 'clsx';
import styled from '@mui/styled-engine';
import styleFunctionSx, { extendSxProp } from '../styleFunctionSx';
import useTheme from '../useTheme';
export default function createBox(options = {}) {
const { themeId, defaultTheme, defaultClassName = 'MuiBox-root', generateClassName } = options;
const BoxRoot = styled('div', {
shouldForwardProp: (prop) => prop !== 'theme' && prop !== 'sx' && prop !== 'as',
})(styleFunctionSx);
const Box = React.forwardRef(function Box(inProps, ref) {
const theme = useTheme(defaultTheme);
const { className, component = 'div', ...other } = extendSxProp(inProps);
return (
<BoxRoot
as={component}
ref={ref}
className={clsx(
className,
generateClassName ? generateClassName(defaultClassName) : defaultClassName,
)}
theme={themeId ? theme[themeId] || theme : theme}
{...other}
/>
);
});
return Box;
}

View File

@@ -0,0 +1,55 @@
import { createBox } from '@mui/system';
const Box = createBox();
interface TestProps {
test?: string;
}
function Test(props: TestProps) {
const { test, ...other } = props;
return <span {...other}>{test}</span>;
}
function ResponsiveTest() {
<Box sx={{ p: [2, 3, 4] }} />;
<Box sx={{ p: { xs: 2, sm: 3, md: 4 } }} />;
<Box sx={{ fontSize: [12, 18, 24] }}>Array API</Box>;
<Box
sx={{
fontSize: {
xs: 12,
sm: 18,
md: 24,
},
}}
>
Object API
</Box>;
}
function GapTest() {
<Box
sx={{
width: '100%',
display: 'flex',
alignItems: 'center',
flex: '1 0',
gap: '16px',
}}
>
Gap
</Box>;
}
function ComponentPropTest() {
<Box component="img" src="https://mui.com/" alt="Material UI" />;
<Box component={Test} test="Test string" />;
}
function ThemeCallbackTest() {
<Box sx={{ background: (theme) => theme.palette.primary.main }} />;
<Box sx={{ '&:hover': (theme) => ({ background: theme.palette.primary.main }) }} />;
<Box sx={{ '& .some-class': (theme) => ({ background: theme.palette.primary.main }) }} />;
<Box maxWidth={(theme) => theme.breakpoints.values.sm} />;
}

View File

@@ -0,0 +1,96 @@
import { expect } from 'chai';
import { spy } from 'sinon';
import { createRenderer } from '@mui/internal-test-utils';
import { createBox, ThemeProvider } from '@mui/system';
describe('createBox', () => {
const { render } = createRenderer();
it('should work', () => {
const Box = createBox();
const { container } = render(<Box />);
expect(container.firstChild).to.have.class('MuiBox-root');
});
it('should use defaultTheme if provided', () => {
const Box = createBox({ defaultTheme: { palette: { primary: { main: 'rgb(255, 0, 0)' } } } });
const { container } = render(<Box color="primary.main">Content</Box>);
expect(container.firstChild).toHaveComputedStyle({ color: 'rgb(255, 0, 0)' });
});
it('should use theme from Context if provided', () => {
const Box = createBox({ defaultTheme: { palette: { primary: { main: 'rgb(255, 0, 0)' } } } });
const { container } = render(
<ThemeProvider theme={{ palette: { primary: { main: 'rgb(0, 255, 0)' } } }}>
<Box color="primary.main">Content</Box>
</ThemeProvider>,
);
expect(container.firstChild).toHaveComputedStyle({ color: 'rgb(0, 255, 0)' });
});
it('able to customize default className', () => {
const Box = createBox({ defaultClassName: 'FooBarBox' });
const { container } = render(<Box />);
expect(container.firstChild).to.have.class('FooBarBox');
});
it('use generateClassName if provided', () => {
const Box = createBox({ generateClassName: () => 'CustomBox-root' });
const { container } = render(<Box />);
expect(container.firstChild).to.have.class('CustomBox-root');
});
it('generateClassName should receive defaultClassName if provided', () => {
const Box = createBox({
defaultClassName: 'FooBarBox',
generateClassName: (name) => name.replace('FooBar', ''),
});
const { container } = render(<Box />);
expect(container.firstChild).to.have.class('Box');
});
it('should accept sx prop', () => {
const Box = createBox();
const { container } = render(<Box sx={{ color: 'rgb(255, 0, 0)' }}>Content</Box>);
expect(container.firstChild).toHaveComputedStyle({ color: 'rgb(255, 0, 0)' });
});
it('should call styleFunctionSx once', () => {
const Box = createBox();
const spySx = spy();
render(<Box sx={spySx}>Content</Box>);
expect(spySx.callCount).to.equal(2); // React 18 renders twice in strict mode.
});
it('should still call styleFunctionSx once', () => {
const Box = createBox();
const spySx = spy();
render(
<Box component={Box} sx={spySx}>
Content
</Box>,
);
expect(spySx.callCount).to.equal(2); // React 18 renders twice in strict mode.
});
it('overridable via `component` prop', () => {
const Box = createBox();
const { container } = render(<Box component="span" />);
expect(container.firstChild).to.have.tagName('span');
});
it('should not have `as` and `theme` attribute spread to DOM', () => {
const Box = createBox();
const { container } = render(<Box component="span" />);
expect(container.firstChild).not.to.have.attribute('as');
expect(container.firstChild).not.to.have.attribute('theme');
});
});

View File

@@ -0,0 +1 @@
export { default } from './createBox';

View File

@@ -0,0 +1,85 @@
import { OverridableStringUnion } from '@mui/types';
export interface BreakpointOverrides {}
export type Breakpoint = OverridableStringUnion<
'xs' | 'sm' | 'md' | 'lg' | 'xl',
BreakpointOverrides
>;
export const keys: Breakpoint[];
// Keep in sync with docs/src/pages/customization/breakpoints/breakpoints.md
// #host-reference
export interface Breakpoints {
keys: Breakpoint[];
/**
* Each breakpoint (a key) matches with a fixed screen width (a value).
* @default {
* // extra-small
* xs: 0,
* // small
* sm: 600,
* // medium
* md: 900,
* // large
* lg: 1200,
* // extra-large
* xl: 1536,
* }
*/
values: { [key in Breakpoint]: number };
/**
* @param key - A breakpoint key (`xs`, `sm`, etc.) or a screen width number in px.
* @returns A media query string ready to be used with most styling solutions, which matches screen widths greater than the screen size given by the breakpoint key (inclusive).
* @see [API documentation](https://mui.com/material-ui/customization/breakpoints/#theme-breakpoints-up-key-media-query)
*/
up: (key: Breakpoint | number) => string;
/**
* @param key - A breakpoint key (`xs`, `sm`, etc.) or a screen width number in px.
* @returns A media query string ready to be used with most styling solutions, which matches screen widths less than the screen size given by the breakpoint key (exclusive).
* @see [API documentation](https://mui.com/material-ui/customization/breakpoints/#theme-breakpoints-down-key-media-query)
*/
down: (key: Breakpoint | number) => string;
/**
* @param start - A breakpoint key (`xs`, `sm`, etc.) or a screen width number in px.
* @param end - A breakpoint key (`xs`, `sm`, etc.) or a screen width number in px.
* @returns A media query string ready to be used with most styling solutions, which matches screen widths greater than
* the screen size given by the breakpoint key in the first argument (inclusive) and less than the screen size given by the breakpoint key in the second argument (exclusive).
* @see [API documentation](https://mui.com/material-ui/customization/breakpoints/#theme-breakpoints-between-start-end-media-query)
*/
between: (start: Breakpoint | number, end: Breakpoint | number) => string;
/**
* @param key - A breakpoint key (`xs`, `sm`, etc.) or a screen width number in px.
* @returns A media query string ready to be used with most styling solutions, which matches screen widths starting from
* the screen size given by the breakpoint key (inclusive) and stopping at the screen size given by the next breakpoint key (exclusive).
* @see [API documentation](https://mui.com/material-ui/customization/breakpoints/#theme-breakpoints-only-key-media-query)
*/
only: (key: Breakpoint) => string;
/**
* @param key - A breakpoint key (`xs`, `sm`, etc.).
* @returns A media query string ready to be used with most styling solutions, which matches screen widths stopping at
* the screen size given by the breakpoint key (exclusive) and starting at the screen size given by the next breakpoint key (inclusive).
*/
not: (key: Breakpoint) => string;
/**
* The unit used for the breakpoint's values.
* @default 'px'
*/
unit?: string | undefined;
}
export interface BreakpointsOptions extends Partial<Breakpoints> {
/**
* The increment divided by 100 used to implement exclusive breakpoints.
* For example, `step: 5` means that `down(500)` will result in `'(max-width: 499.95px)'`.
* @default 5
*/
step?: number | undefined;
/**
* The unit used for the breakpoint's values.
* @default 'px'
*/
unit?: string | undefined;
}
export default function createBreakpoints(options: BreakpointsOptions): Breakpoints;

View File

@@ -0,0 +1,92 @@
// Sorted ASC by size. That's important.
// It can't be configured as it's used statically for propTypes.
export const breakpointKeys = ['xs', 'sm', 'md', 'lg', 'xl'];
const sortBreakpointsValues = (values) => {
const breakpointsAsArray = Object.keys(values).map((key) => ({ key, val: values[key] })) || [];
// Sort in ascending order
breakpointsAsArray.sort((breakpoint1, breakpoint2) => breakpoint1.val - breakpoint2.val);
return breakpointsAsArray.reduce((acc, obj) => {
return { ...acc, [obj.key]: obj.val };
}, {});
};
// Keep in mind that @media is inclusive by the CSS specification.
export default function createBreakpoints(breakpoints) {
const {
// The breakpoint **start** at this value.
// For instance with the first breakpoint xs: [xs, sm).
values = {
xs: 0, // phone
sm: 600, // tablet
md: 900, // small laptop
lg: 1200, // desktop
xl: 1536, // large screen
},
unit = 'px',
step = 5,
...other
} = breakpoints;
const sortedValues = sortBreakpointsValues(values);
const keys = Object.keys(sortedValues);
function up(key) {
const value = typeof values[key] === 'number' ? values[key] : key;
return `@media (min-width:${value}${unit})`;
}
function down(key) {
const value = typeof values[key] === 'number' ? values[key] : key;
return `@media (max-width:${value - step / 100}${unit})`;
}
function between(start, end) {
const endIndex = keys.indexOf(end);
return (
`@media (min-width:${
typeof values[start] === 'number' ? values[start] : start
}${unit}) and ` +
`(max-width:${
(endIndex !== -1 && typeof values[keys[endIndex]] === 'number'
? values[keys[endIndex]]
: end) -
step / 100
}${unit})`
);
}
function only(key) {
if (keys.indexOf(key) + 1 < keys.length) {
return between(key, keys[keys.indexOf(key) + 1]);
}
return up(key);
}
function not(key) {
// handle first and last key separately, for better readability
const keyIndex = keys.indexOf(key);
if (keyIndex === 0) {
return up(keys[1]);
}
if (keyIndex === keys.length - 1) {
return down(keys[keyIndex]);
}
return between(key, keys[keys.indexOf(key) + 1]).replace('@media', '@media not all and');
}
return {
keys,
values: sortedValues,
up,
down,
between,
only,
not,
unit,
...other,
};
}

View File

@@ -0,0 +1,145 @@
import { expect } from 'chai';
import createBreakpoints from './createBreakpoints';
describe('createBreakpoints', () => {
const breakpoints = createBreakpoints({});
const customBreakpoints = createBreakpoints({
values: {
mobile: 0,
tablet: 640,
laptop: 1024,
desktop: 1280,
},
});
it('should sort the values', () => {
const orderedValues = createBreakpoints({
values: {
mobile: 0,
tablet: 640,
laptop: 1024,
desktop: 1280,
},
});
const unorderedValues = createBreakpoints({
values: {
tablet: 640,
mobile: 0,
laptop: 1024,
desktop: 1280,
},
});
expect(unorderedValues.keys).to.deep.equal(orderedValues.keys);
expect(unorderedValues.values).to.deep.equal(orderedValues.values);
});
describe('up', () => {
it('should work for xs', () => {
expect(breakpoints.up('xs')).to.equal('@media (min-width:0px)');
});
it('should work for md', () => {
expect(breakpoints.up('md')).to.equal('@media (min-width:900px)');
});
it('should work for custom breakpoints', () => {
expect(customBreakpoints.up('laptop')).to.equal('@media (min-width:1024px)');
});
});
describe('down', () => {
it('should work', () => {
expect(breakpoints.down('sm')).to.equal('@media (max-width:599.95px)');
});
it('should work for md', () => {
expect(breakpoints.down('md')).to.equal('@media (max-width:899.95px)');
});
it('should work for xs', () => {
expect(breakpoints.down('xs')).to.equal('@media (max-width:-0.05px)');
});
it('should accept a number', () => {
expect(breakpoints.down(600)).to.equal('@media (max-width:599.95px)');
});
it('should work for xl', () => {
expect(breakpoints.down('xl')).to.equal('@media (max-width:1535.95px)');
});
it('should work for custom breakpoints', () => {
expect(customBreakpoints.down('laptop')).to.equal('@media (max-width:1023.95px)');
});
it('should work for the largest of custom breakpoints', () => {
expect(customBreakpoints.down('desktop')).to.equal('@media (max-width:1279.95px)');
});
});
describe('between', () => {
it('should work', () => {
expect(breakpoints.between('sm', 'md')).to.equal(
'@media (min-width:600px) and (max-width:899.95px)',
);
});
it('should accept numbers', () => {
expect(breakpoints.between(600, 800)).to.equal(
'@media (min-width:600px) and (max-width:799.95px)',
);
});
it('should work on largest breakpoints', () => {
expect(breakpoints.between('lg', 'xl')).to.equal(
'@media (min-width:1200px) and (max-width:1535.95px)',
);
});
it('should work for custom breakpoints', () => {
expect(customBreakpoints.between('tablet', 'laptop')).to.equal(
'@media (min-width:640px) and (max-width:1023.95px)',
);
});
});
describe('only', () => {
it('should work', () => {
expect(breakpoints.only('md')).to.equal('@media (min-width:900px) and (max-width:1199.95px)');
});
it('on xl should call up', () => {
expect(breakpoints.only('xl')).to.equal('@media (min-width:1536px)');
});
it('should work for custom breakpoints', () => {
expect(customBreakpoints.only('tablet')).to.equal(
'@media (min-width:640px) and (max-width:1023.95px)',
);
});
});
describe('not', () => {
it('should work', () => {
expect(breakpoints.not('md')).to.equal(
'@media not all and (min-width:900px) and (max-width:1199.95px)',
);
});
it('should invert up for xl', () => {
expect(breakpoints.not('xl')).to.equal('@media (max-width:1535.95px)');
});
it('should invert down for xs', () => {
expect(breakpoints.not('xs')).to.equal('@media (min-width:600px)');
});
it('should work for custom breakpoints', () => {
expect(customBreakpoints.not('tablet')).to.equal(
'@media not all and (min-width:640px) and (max-width:1023.95px)',
);
});
});
});

View File

@@ -0,0 +1,3 @@
/** This export is intended for internal integration with Pigment CSS */
/* eslint-disable import/prefer-default-export */
export { default as unstable_createBreakpoints } from './createBreakpoints';

View File

@@ -0,0 +1,3 @@
/** This export is intended for internal integration with Pigment CSS */
/* eslint-disable import/prefer-default-export */
export { default as unstable_createBreakpoints } from './createBreakpoints';

View File

@@ -0,0 +1,39 @@
import * as React from 'react';
import {
CreateMUIStyled as CreateMUIStyledStyledEngine,
CSSInterpolation,
} from '@mui/styled-engine';
import styleFunctionSx, { SxProps } from '../styleFunctionSx';
import { Theme as DefaultTheme } from '../createTheme';
export function shouldForwardProp(propName: PropertyKey): boolean;
export interface MUIStyledCommonProps<Theme extends object = DefaultTheme> {
theme?: Theme;
as?: React.ElementType;
sx?: SxProps<Theme>;
}
export interface MuiStyledOptions {
name?: string;
slot?: string;
// The difference between Interpolation and CSSInterpolation is that the former supports functions based on props
// If we want to support props in the overrides, we will need to change the CSSInterpolation to Interpolation<Props>
overridesResolver?: (props: any, styles: Record<string, CSSInterpolation>) => CSSInterpolation;
skipVariantsResolver?: boolean;
skipSx?: boolean;
}
export type CreateMUIStyled<Theme extends object = DefaultTheme> = CreateMUIStyledStyledEngine<
MUIStyledCommonProps<Theme>,
MuiStyledOptions,
Theme
>;
export default function createStyled<Theme extends object = DefaultTheme>(options?: {
themeId?: string;
defaultTheme?: Theme;
rootShouldForwardProp?: (prop: PropertyKey) => boolean;
slotShouldForwardProp?: (prop: PropertyKey) => boolean;
styleFunctionSx?: typeof styleFunctionSx;
}): CreateMUIStyled<Theme>;

View File

@@ -0,0 +1,357 @@
import styledEngineStyled, {
internal_mutateStyles as mutateStyles,
internal_serializeStyles as serializeStyles,
} from '@mui/styled-engine';
import { isPlainObject } from '@mui/utils/deepmerge';
import capitalize from '@mui/utils/capitalize';
import getDisplayName from '@mui/utils/getDisplayName';
import createTheme from '../createTheme';
import styleFunctionSx from '../styleFunctionSx';
import preprocessStyles from '../preprocessStyles';
/* eslint-disable no-underscore-dangle */
/* eslint-disable no-labels */
/* eslint-disable no-lone-blocks */
export const systemDefaultTheme = createTheme();
// Update /system/styled/#api in case if this changes
export function shouldForwardProp(prop) {
return prop !== 'ownerState' && prop !== 'theme' && prop !== 'sx' && prop !== 'as';
}
function shallowLayer(serialized, layerName) {
if (
layerName &&
serialized &&
typeof serialized === 'object' &&
serialized.styles &&
!serialized.styles.startsWith('@layer') // only add the layer if it is not already there.
) {
serialized.styles = `@layer ${layerName}{${String(serialized.styles)}}`;
}
return serialized;
}
function defaultOverridesResolver(slot) {
if (!slot) {
return null;
}
return (_props, styles) => styles[slot];
}
function attachTheme(props, themeId, defaultTheme) {
props.theme = isObjectEmpty(props.theme) ? defaultTheme : props.theme[themeId] || props.theme;
}
function processStyle(props, style, layerName) {
/*
* Style types:
* - null/undefined
* - string
* - CSS style object: { [cssKey]: [cssValue], variants }
* - Processed style object: { style, variants, isProcessed: true }
* - Array of any of the above
*/
const resolvedStyle = typeof style === 'function' ? style(props) : style;
if (Array.isArray(resolvedStyle)) {
return resolvedStyle.flatMap((subStyle) => processStyle(props, subStyle, layerName));
}
if (Array.isArray(resolvedStyle?.variants)) {
let rootStyle;
if (resolvedStyle.isProcessed) {
rootStyle = layerName ? shallowLayer(resolvedStyle.style, layerName) : resolvedStyle.style;
} else {
const { variants, ...otherStyles } = resolvedStyle;
rootStyle = layerName ? shallowLayer(serializeStyles(otherStyles), layerName) : otherStyles;
}
return processStyleVariants(props, resolvedStyle.variants, [rootStyle], layerName);
}
if (resolvedStyle?.isProcessed) {
return layerName
? shallowLayer(serializeStyles(resolvedStyle.style), layerName)
: resolvedStyle.style;
}
return layerName ? shallowLayer(serializeStyles(resolvedStyle), layerName) : resolvedStyle;
}
function processStyleVariants(props, variants, results = [], layerName = undefined) {
let mergedState; // We might not need it, initialized lazily
variantLoop: for (let i = 0; i < variants.length; i += 1) {
const variant = variants[i];
if (typeof variant.props === 'function') {
mergedState ??= { ...props, ...props.ownerState, ownerState: props.ownerState };
if (!variant.props(mergedState)) {
continue;
}
} else {
for (const key in variant.props) {
if (props[key] !== variant.props[key] && props.ownerState?.[key] !== variant.props[key]) {
continue variantLoop;
}
}
}
if (typeof variant.style === 'function') {
mergedState ??= { ...props, ...props.ownerState, ownerState: props.ownerState };
results.push(
layerName
? shallowLayer(serializeStyles(variant.style(mergedState)), layerName)
: variant.style(mergedState),
);
} else {
results.push(
layerName ? shallowLayer(serializeStyles(variant.style), layerName) : variant.style,
);
}
}
return results;
}
export default function createStyled(input = {}) {
const {
themeId,
defaultTheme = systemDefaultTheme,
rootShouldForwardProp = shouldForwardProp,
slotShouldForwardProp = shouldForwardProp,
} = input;
function styleAttachTheme(props) {
attachTheme(props, themeId, defaultTheme);
}
const styled = (tag, inputOptions = {}) => {
// If `tag` is already a styled component, filter out the `sx` style function
// to prevent unnecessary styles generated by the composite components.
mutateStyles(tag, (styles) => styles.filter((style) => style !== styleFunctionSx));
const {
name: componentName,
slot: componentSlot,
skipVariantsResolver: inputSkipVariantsResolver,
skipSx: inputSkipSx,
// TODO v6: remove `lowercaseFirstLetter()` in the next major release
// For more details: https://github.com/mui/material-ui/pull/37908
overridesResolver = defaultOverridesResolver(lowercaseFirstLetter(componentSlot)),
...options
} = inputOptions;
const layerName =
(componentName && componentName.startsWith('Mui')) || !!componentSlot
? 'components'
: 'custom';
// if skipVariantsResolver option is defined, take the value, otherwise, true for root and false for other slots.
const skipVariantsResolver =
inputSkipVariantsResolver !== undefined
? inputSkipVariantsResolver
: // TODO v6: remove `Root` in the next major release
// For more details: https://github.com/mui/material-ui/pull/37908
(componentSlot && componentSlot !== 'Root' && componentSlot !== 'root') || false;
const skipSx = inputSkipSx || false;
let shouldForwardPropOption = shouldForwardProp;
// TODO v6: remove `Root` in the next major release
// For more details: https://github.com/mui/material-ui/pull/37908
if (componentSlot === 'Root' || componentSlot === 'root') {
shouldForwardPropOption = rootShouldForwardProp;
} else if (componentSlot) {
// any other slot specified
shouldForwardPropOption = slotShouldForwardProp;
} else if (isStringTag(tag)) {
// for string (html) tag, preserve the behavior in emotion & styled-components.
shouldForwardPropOption = undefined;
}
const defaultStyledResolver = styledEngineStyled(tag, {
shouldForwardProp: shouldForwardPropOption,
label: generateStyledLabel(componentName, componentSlot),
...options,
});
const transformStyle = (style) => {
// - On the server Emotion doesn't use React.forwardRef for creating components, so the created
// component stays as a function. This condition makes sure that we do not interpolate functions
// which are basically components used as a selectors.
// - `style` could be a styled component from a babel plugin for component selectors, This condition
// makes sure that we do not interpolate them.
if (style.__emotion_real === style) {
return style;
}
if (typeof style === 'function') {
return function styleFunctionProcessor(props) {
return processStyle(props, style, props.theme.modularCssLayers ? layerName : undefined);
};
}
if (isPlainObject(style)) {
const serialized = preprocessStyles(style);
return function styleObjectProcessor(props) {
if (!serialized.variants) {
return props.theme.modularCssLayers
? shallowLayer(serialized.style, layerName)
: serialized.style;
}
return processStyle(
props,
serialized,
props.theme.modularCssLayers ? layerName : undefined,
);
};
}
return style;
};
const muiStyledResolver = (...expressionsInput) => {
const expressionsHead = [];
const expressionsBody = expressionsInput.map(transformStyle);
const expressionsTail = [];
// Preprocess `props` to set the scoped theme value.
// This must run before any other expression.
expressionsHead.push(styleAttachTheme);
if (componentName && overridesResolver) {
expressionsTail.push(function styleThemeOverrides(props) {
const theme = props.theme;
const styleOverrides = theme.components?.[componentName]?.styleOverrides;
if (!styleOverrides) {
return null;
}
const resolvedStyleOverrides = {};
// TODO: v7 remove iteration and use `resolveStyleArg(styleOverrides[slot])` directly
// eslint-disable-next-line guard-for-in
for (const slotKey in styleOverrides) {
resolvedStyleOverrides[slotKey] = processStyle(
props,
styleOverrides[slotKey],
props.theme.modularCssLayers ? 'theme' : undefined,
);
}
return overridesResolver(props, resolvedStyleOverrides);
});
}
if (componentName && !skipVariantsResolver) {
expressionsTail.push(function styleThemeVariants(props) {
const theme = props.theme;
const themeVariants = theme?.components?.[componentName]?.variants;
if (!themeVariants) {
return null;
}
return processStyleVariants(
props,
themeVariants,
[],
props.theme.modularCssLayers ? 'theme' : undefined,
);
});
}
if (!skipSx) {
expressionsTail.push(styleFunctionSx);
}
// This function can be called as a tagged template, so the first argument would contain
// CSS `string[]` values.
if (Array.isArray(expressionsBody[0])) {
const inputStrings = expressionsBody.shift();
// We need to add placeholders in the tagged template for the custom functions we have
// possibly added (attachTheme, overrides, variants, and sx).
const placeholdersHead = new Array(expressionsHead.length).fill('');
const placeholdersTail = new Array(expressionsTail.length).fill('');
let outputStrings;
// prettier-ignore
{
outputStrings = [...placeholdersHead, ...inputStrings, ...placeholdersTail];
outputStrings.raw = [...placeholdersHead, ...inputStrings.raw, ...placeholdersTail];
}
// The only case where we put something before `attachTheme`
expressionsHead.unshift(outputStrings);
}
const expressions = [...expressionsHead, ...expressionsBody, ...expressionsTail];
const Component = defaultStyledResolver(...expressions);
if (tag.muiName) {
Component.muiName = tag.muiName;
}
if (process.env.NODE_ENV !== 'production') {
Component.displayName = generateDisplayName(componentName, componentSlot, tag);
}
return Component;
};
if (defaultStyledResolver.withConfig) {
muiStyledResolver.withConfig = defaultStyledResolver.withConfig;
}
return muiStyledResolver;
};
return styled;
}
function generateDisplayName(componentName, componentSlot, tag) {
if (componentName) {
return `${componentName}${capitalize(componentSlot || '')}`;
}
return `Styled(${getDisplayName(tag)})`;
}
function generateStyledLabel(componentName, componentSlot) {
let label;
if (process.env.NODE_ENV !== 'production') {
if (componentName) {
// TODO v6: remove `lowercaseFirstLetter()` in the next major release
// For more details: https://github.com/mui/material-ui/pull/37908
label = `${componentName}-${lowercaseFirstLetter(componentSlot || 'Root')}`;
}
}
return label;
}
function isObjectEmpty(object) {
// eslint-disable-next-line
for (const _ in object) {
return false;
}
return true;
}
// https://github.com/emotion-js/emotion/blob/26ded6109fcd8ca9875cc2ce4564fee678a3f3c5/packages/styled/src/utils.js#L40
function isStringTag(tag) {
return (
typeof tag === 'string' &&
// 96 is one less than the char code
// for "a" so this is checking that
// it's a lowercase character
tag.charCodeAt(0) > 96
);
}
function lowercaseFirstLetter(string) {
if (!string) {
return string;
}
return string.charAt(0).toLowerCase() + string.slice(1);
}

View File

@@ -0,0 +1,2 @@
export { default } from './createStyled';
export * from './createStyled';

View File

@@ -0,0 +1,84 @@
import { expect } from 'chai';
import applyStyles from './applyStyles';
describe('applyStyles', () => {
it('should apply styles for media prefers-color-scheme', () => {
const theme = {
vars: {},
colorSchemes: { light: true },
getColorSchemeSelector: (colorScheme: string) => {
return `@media (prefers-color-scheme: ${colorScheme})`;
},
};
const styles = { background: '#e5e5e5' };
expect(applyStyles.call(theme, 'light', styles)).to.deep.equal({
'@media (prefers-color-scheme: light)': styles,
});
});
it('should apply styles for a class selector', () => {
const theme = {
vars: {},
colorSchemes: { light: true },
getColorSchemeSelector: (colorScheme: string) => {
return `.${colorScheme}`;
},
};
const styles = { background: '#e5e5e5' };
expect(applyStyles.call(theme, 'light', styles)).to.deep.equal({
'*:where(.light) &': styles,
});
});
it('should apply styles for a data attribute selector', () => {
const theme = {
vars: {},
colorSchemes: { light: true },
getColorSchemeSelector: (colorScheme: string) => {
return `[data-color-scheme-${colorScheme}]`;
},
};
const styles = { background: '#e5e5e5' };
expect(applyStyles.call(theme, 'light', styles)).to.deep.equal({
'*:where([data-color-scheme-light]) &': styles,
});
});
it('should apply styles for a data attribute selector with &', () => {
const theme = {
vars: {},
colorSchemes: { light: true },
getColorSchemeSelector: (colorScheme: string) => {
return `[data-color-scheme="${colorScheme}"] &`;
},
};
const styles = { background: '#e5e5e5' };
expect(applyStyles.call(theme, 'light', styles)).to.deep.equal({
'*:where([data-color-scheme="light"]) &': styles,
});
});
it('should not apply styles if colorScheme does not exist', () => {
const theme = {
vars: {},
colorSchemes: { light: true },
getColorSchemeSelector: (colorScheme: string) => {
return `[data-color-scheme="${colorScheme}"] &`;
},
};
const styles = { background: '#e5e5e5' };
expect(applyStyles.call(theme, 'dark', styles)).to.deep.equal({});
});
it('should return the styles directly if selector is &', () => {
const theme = {
vars: {},
colorSchemes: { light: true },
getColorSchemeSelector: () => {
return '&';
},
};
const styles = { background: '#e5e5e5' };
expect(applyStyles.call(theme, 'light', styles)).to.deep.equal(styles);
});
});

View File

@@ -0,0 +1,99 @@
import { CSSObject } from '@mui/styled-engine';
export interface ApplyStyles<K extends string> {
(key: K, styles: CSSObject): CSSObject;
}
/**
* A universal utility to style components with multiple color modes. Always use it from the theme object.
* It works with:
* - [Basic theme](https://mui.com/material-ui/customization/dark-mode/)
* - [CSS theme variables](https://mui.com/material-ui/customization/css-theme-variables/overview/)
* - Zero-runtime engine
*
* Tips: Use an array over object spread and place `theme.applyStyles()` last.
*
* With the styled function:
* ✅ [{ background: '#e5e5e5' }, theme.applyStyles('dark', { background: '#1c1c1c' })]
* 🚫 { background: '#e5e5e5', ...theme.applyStyles('dark', { background: '#1c1c1c' })}
*
* With the sx prop:
* ✅ [{ background: '#e5e5e5' }, theme => theme.applyStyles('dark', { background: '#1c1c1c' })]
* 🚫 { background: '#e5e5e5', ...theme => theme.applyStyles('dark', { background: '#1c1c1c' })}
*
* @example
* 1. using with `styled`:
* ```jsx
* const Component = styled('div')(({ theme }) => [
* { background: '#e5e5e5' },
* theme.applyStyles('dark', {
* background: '#1c1c1c',
* color: '#fff',
* }),
* ]);
* ```
*
* @example
* 2. using with `sx` prop:
* ```jsx
* <Box sx={[
* { background: '#e5e5e5' },
* theme => theme.applyStyles('dark', {
* background: '#1c1c1c',
* color: '#fff',
* }),
* ]}
* />
* ```
*
* @example
* 3. theming a component:
* ```jsx
* extendTheme({
* components: {
* MuiButton: {
* styleOverrides: {
* root: ({ theme }) => [
* { background: '#e5e5e5' },
* theme.applyStyles('dark', {
* background: '#1c1c1c',
* color: '#fff',
* }),
* ],
* },
* }
* }
* })
*```
*/
export default function applyStyles<K extends string>(key: K, styles: CSSObject) {
// @ts-expect-error this is 'any' type
const theme = this as {
palette: { mode: 'light' | 'dark' };
vars?: any;
colorSchemes?: Record<K, any>;
getColorSchemeSelector?: (scheme: string) => string;
};
if (theme.vars) {
if (!theme.colorSchemes?.[key] || typeof theme.getColorSchemeSelector !== 'function') {
return {};
}
// If CssVarsProvider is used as a provider, returns '*:where({selector}) &'
let selector = theme.getColorSchemeSelector(key);
if (selector === '&') {
return styles;
}
if (selector.includes('data-') || selector.includes('.')) {
// '*' is required as a workaround for Emotion issue (https://github.com/emotion-js/emotion/issues/2836)
selector = `*:where(${selector.replace(/\s*&$/, '')}) &`;
}
return {
[selector]: styles,
};
}
if (theme.palette.mode === key) {
return styles;
}
return {};
}

View File

@@ -0,0 +1,70 @@
import { expect } from 'chai';
import createSpacing, { Spacing } from './createSpacing';
describe('createSpacing', () => {
it('should be configurable', () => {
let spacing: Spacing;
spacing = createSpacing();
expect(spacing(1)).to.equal('8px');
spacing = createSpacing(10);
expect(spacing(1)).to.equal('10px');
spacing = createSpacing([0, 8, 16]);
expect(spacing(2)).to.equal('16px');
spacing = createSpacing(['0rem', '8rem', '16rem']);
expect(spacing(2)).to.equal('16rem');
spacing = createSpacing((factor: number) => factor ** 2);
expect(spacing(2)).to.equal('4px');
spacing = createSpacing((factor: number) => `${0.25 * factor}rem`);
expect(spacing(2)).to.equal('0.5rem');
spacing = createSpacing('0.5rem');
expect(spacing(2)).to.equal('calc(2 * 0.5rem)');
});
it('should support recursion', () => {
const spacing = createSpacing();
createSpacing(spacing);
});
it('should support a default value when no arguments are provided', () => {
let spacing;
spacing = createSpacing();
expect(spacing()).to.equal('8px');
spacing = createSpacing((factor: number) => `${0.25 * factor}rem`);
expect(spacing()).to.equal('0.25rem');
});
it('should support multiple arguments', () => {
let spacing;
spacing = createSpacing();
expect(spacing(1, 2)).to.equal('8px 16px');
spacing = createSpacing((factor: number) => `${0.25 * factor}rem`);
expect(spacing(1, 2)).to.equal('0.25rem 0.5rem');
});
it('should support string arguments', () => {
let spacing;
spacing = createSpacing();
expect(spacing(1, 'auto')).to.equal('8px auto');
spacing = createSpacing((factor: number | string) =>
typeof factor === 'string' ? factor : `${0.25 * factor}rem`,
);
expect(spacing(1, 'auto', 2, 3)).to.equal('0.25rem auto 0.5rem 0.75rem');
});
it('should support valid CSS unit', () => {
const spacing = createSpacing();
expect(spacing('16px')).to.equal('16px');
expect(spacing('1rem')).to.equal('1rem');
});
describe('warnings', () => {
it('should warn for wrong input', () => {
expect(() => {
createSpacing({
// @ts-expect-error
unit: 4,
});
}).toErrorDev('MUI: The `theme.spacing` value ([object Object]) is invalid');
});
});
});

View File

@@ -0,0 +1,64 @@
import { createUnarySpacing } from '../spacing';
export type SpacingOptions =
| number
| string
| Spacing
| ((abs: number) => number | string)
| ((abs: number | string) => number | string)
| ReadonlyArray<string | number>;
export type SpacingArgument = number | string;
// The different signatures imply different meaning for their arguments that can't be expressed structurally.
// We express the difference with variable names.
export interface Spacing {
(): string;
(value: SpacingArgument): string;
(topBottom: SpacingArgument, rightLeft: SpacingArgument): string;
(top: SpacingArgument, rightLeft: SpacingArgument, bottom: SpacingArgument): string;
(
top: SpacingArgument,
right: SpacingArgument,
bottom: SpacingArgument,
left: SpacingArgument,
): string;
}
export default function createSpacing(
spacingInput: SpacingOptions = 8,
// Material Design layouts are visually balanced. Most measurements align to an 8dp grid, which aligns both spacing and the overall layout.
// Smaller components, such as icons, can align to a 4dp grid.
// https://m2.material.io/design/layout/understanding-layout.html
transform = createUnarySpacing({
spacing: spacingInput,
}),
): Spacing {
// Already transformed.
if ((spacingInput as any).mui) {
return spacingInput as Spacing;
}
const spacing = (...argsInput: ReadonlyArray<number | string>): string => {
if (process.env.NODE_ENV !== 'production') {
if (!(argsInput.length <= 4)) {
console.error(
`MUI: Too many arguments provided, expected between 0 and 4, got ${argsInput.length}`,
);
}
}
const args = argsInput.length === 0 ? [1] : argsInput;
return args
.map((argument) => {
const output = transform(argument);
return typeof output === 'number' ? `${output}px` : output;
})
.join(' ');
};
spacing.mui = true;
return spacing;
}

View File

@@ -0,0 +1,61 @@
import { CSSObject } from '@mui/styled-engine';
import { Breakpoints, BreakpointsOptions } from '../createBreakpoints/createBreakpoints';
import { Shape, ShapeOptions } from './shape';
import { Spacing, SpacingOptions } from './createSpacing';
import { SxConfig, SxProps } from '../styleFunctionSx';
import { ApplyStyles } from './applyStyles';
import { CssContainerQueries } from '../cssContainerQueries';
export {
Breakpoint,
Breakpoints,
BreakpointOverrides,
} from '../createBreakpoints/createBreakpoints';
export type Direction = 'ltr' | 'rtl';
export interface Typography {}
export interface Mixins {}
export interface Shadows {}
export interface Transitions {}
export interface ZIndex {}
export interface ThemeOptions {
shape?: ShapeOptions;
breakpoints?: BreakpointsOptions;
direction?: Direction;
mixins?: Mixins;
palette?: Record<string, any>;
shadows?: Shadows;
spacing?: SpacingOptions;
transitions?: Transitions;
components?: Record<string, any>;
typography?: Typography;
zIndex?: ZIndex;
unstable_sxConfig?: SxConfig;
}
export interface Theme extends CssContainerQueries {
shape: Shape;
breakpoints: Breakpoints;
direction: Direction;
palette: Record<string, any> & { mode: 'light' | 'dark' };
shadows?: Shadows;
spacing: Spacing;
transitions?: Transitions;
components?: Record<string, any>;
mixins?: Mixins;
typography?: Typography;
zIndex?: ZIndex;
applyStyles: ApplyStyles<'light' | 'dark'>;
unstable_sxConfig: SxConfig;
unstable_sx: (props: SxProps<Theme>) => CSSObject;
}
/**
* Generate a theme base on the options received.
* @param options Takes an incomplete theme object and adds the missing parts.
* @param args Deep merge the arguments with the about to be returned theme.
* @returns A complete, ready-to-use theme object.
*/
export default function createTheme(options?: ThemeOptions, ...args: object[]): Theme;

View File

@@ -0,0 +1,53 @@
import deepmerge from '@mui/utils/deepmerge';
import createBreakpoints from '../createBreakpoints/createBreakpoints';
import cssContainerQueries from '../cssContainerQueries';
import shape from './shape';
import createSpacing from './createSpacing';
import styleFunctionSx from '../styleFunctionSx/styleFunctionSx';
import defaultSxConfig from '../styleFunctionSx/defaultSxConfig';
import applyStyles from './applyStyles';
function createTheme(options = {}, ...args) {
const {
breakpoints: breakpointsInput = {},
palette: paletteInput = {},
spacing: spacingInput,
shape: shapeInput = {},
...other
} = options;
const breakpoints = createBreakpoints(breakpointsInput);
const spacing = createSpacing(spacingInput);
let muiTheme = deepmerge(
{
breakpoints,
direction: 'ltr',
components: {}, // Inject component definitions.
palette: { mode: 'light', ...paletteInput },
spacing,
shape: { ...shape, ...shapeInput },
},
other,
);
muiTheme = cssContainerQueries(muiTheme);
muiTheme.applyStyles = applyStyles;
muiTheme = args.reduce((acc, argument) => deepmerge(acc, argument), muiTheme);
muiTheme.unstable_sxConfig = {
...defaultSxConfig,
...other?.unstable_sxConfig,
};
muiTheme.unstable_sx = function sx(props) {
return styleFunctionSx({
sx: props,
theme: this,
});
};
return muiTheme;
}
export default createTheme;

View File

@@ -0,0 +1,210 @@
import { expect } from 'chai';
import { createRenderer, isJsdom } from '@mui/internal-test-utils';
import { styled, ThemeProvider } from '@mui/system';
import createTheme from '@mui/system/createTheme';
describe('createTheme', () => {
const { render } = createRenderer();
const breakpointsValues = {
xs: 0,
sm: 600,
md: 960,
lg: 1280,
xl: 1920,
};
const round = (value) => Math.round(value * 1e5) / 1e5;
const theme = createTheme({
spacing: (val) => `${val * 10}px`,
breakpoints: {
keys: ['xs', 'sm', 'md', 'lg', 'xl'],
values: breakpointsValues,
up: (key) => {
return `@media (min-width:${breakpointsValues[key]}px)`;
},
},
unit: 'px',
palette: {
primary: {
main: 'rgb(0, 0, 255)',
},
secondary: {
main: 'rgb(0, 255, 0)',
},
},
typography: {
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
fontWeightLight: 300,
fontSize: 14,
body1: {
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
fontSize: '1rem',
letterSpacing: `${round(0.15 / 16)}em`,
fontWeight: 400,
lineHeight: 1.5,
},
body2: {
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
fontSize: `${14 / 16}rem`,
letterSpacing: `${round(0.15 / 14)}em`,
fontWeight: 400,
lineHeight: 1.43,
},
},
});
describe('system', () => {
it.skipIf(isJsdom())('resolves system when used inside styled()', function test() {
const Test = styled('div')(({ theme: t }) =>
t.unstable_sx({
color: 'primary.main',
bgcolor: 'secondary.main',
m: 2,
p: 1,
fontSize: 'fontSize',
maxWidth: 'sm',
}),
);
const { container } = render(
<ThemeProvider theme={theme}>
<Test />
</ThemeProvider>,
);
expect(container.firstChild).toHaveComputedStyle({
color: 'rgb(0, 0, 255)',
backgroundColor: 'rgb(0, 255, 0)',
marginTop: '20px',
marginRight: '20px',
marginBottom: '20px',
marginLeft: '20px',
paddingTop: '10px',
paddingRight: '10px',
paddingBottom: '10px',
paddingLeft: '10px',
fontSize: '14px',
maxWidth: '600px',
});
});
it.skipIf(isJsdom())('resolves system when used inside variants', function test() {
const themeWithVariants = {
...theme,
components: {
MuiTest: {
variants: [
{
props: {}, // all props
style: ({ theme: t }) =>
t.unstable_sx({
color: 'primary.main',
bgcolor: 'secondary.main',
m: 2,
p: 1,
fontSize: 'fontSize',
maxWidth: 'sm',
}),
},
],
},
},
};
const Test = styled('div', { name: 'MuiTest', slot: 'Root' })(({ theme: t }) =>
t.unstable_sx({
color: 'primary.main',
bgcolor: 'secondary.main',
m: 2,
p: 1,
fontSize: 'fontSize',
maxWidth: 'sm',
}),
);
const { container } = render(
<ThemeProvider theme={themeWithVariants}>
<Test />
</ThemeProvider>,
);
expect(container.firstChild).toHaveComputedStyle({
color: 'rgb(0, 0, 255)',
backgroundColor: 'rgb(0, 255, 0)',
marginTop: '20px',
marginRight: '20px',
marginBottom: '20px',
marginLeft: '20px',
paddingTop: '10px',
paddingRight: '10px',
paddingBottom: '10px',
paddingLeft: '10px',
fontSize: '14px',
maxWidth: '600px',
});
});
it('apply correct styles', () => {
const darkTheme = createTheme({
palette: {
mode: 'dark',
primary: {
main: 'rgb(0, 0, 255)',
},
secondary: {
main: 'rgb(0, 255, 0)',
},
},
});
expect(darkTheme.applyStyles('dark', { color: 'red' })).to.deep.equal({
color: 'red',
});
expect(darkTheme.applyStyles('light', { color: 'salmon' })).to.deep.equal({});
// assume switching to light theme
darkTheme.palette.mode = 'light';
expect(darkTheme.applyStyles('dark', { color: 'red' })).to.deep.equal({});
expect(darkTheme.applyStyles('light', { color: 'salmon' })).to.deep.equal({
color: 'salmon',
});
});
it('apply correct styles with new theme', () => {
const darkTheme = createTheme({
palette: {
mode: 'dark',
primary: {
main: 'rgb(0, 0, 255)',
},
secondary: {
main: 'rgb(0, 255, 0)',
},
},
});
const newTheme = { ...darkTheme, palette: { mode: 'light' } };
expect(newTheme.applyStyles('dark', { color: 'red' })).to.deep.equal({});
expect(newTheme.applyStyles('light', { color: 'salmon' })).to.deep.equal({
color: 'salmon',
});
});
});
it('does not throw if used without ThemeProvider', function test() {
const Test = styled('div')(({ theme: t }) =>
t.unstable_sx({
color: 'primary.main',
bgcolor: 'secondary.main',
m: 2,
p: 1,
fontSize: 'fontSize',
maxWidth: 'sm',
}),
);
expect(() => render(<Test />)).not.to.throw();
});
});

View File

@@ -0,0 +1,4 @@
export { default } from './createTheme';
export * from './createTheme';
export { default as unstable_applyStyles } from './applyStyles';
export * from './applyStyles';

View File

@@ -0,0 +1,3 @@
export { default } from './createTheme';
export { default as private_createBreakpoints } from '../createBreakpoints/createBreakpoints';
export { default as unstable_applyStyles } from './applyStyles';

View File

@@ -0,0 +1,9 @@
export interface Shape {
borderRadius: number | string;
}
export type ShapeOptions = Partial<Shape>;
declare const shape: Shape;
export default shape;

View File

@@ -0,0 +1,5 @@
const shape = {
borderRadius: 4,
};
export default shape;

View File

@@ -0,0 +1,126 @@
import { expect } from 'chai';
import createTheme from '@mui/system/createTheme';
import {
isCqShorthand,
sortContainerQueries,
getContainerQuery,
} from '@mui/system/cssContainerQueries';
describe('cssContainerQueries', () => {
it('should return false if the shorthand is not a container query', () => {
expect(isCqShorthand(['xs', 'sm', 'md'], '@container (min-width:600px)')).to.equal(false);
expect(isCqShorthand(['xs', 'sm', 'md'], '@media (min-width:600px)')).to.equal(false);
expect(isCqShorthand(['xs', 'sm', 'md'], '@page')).to.equal(false);
expect(isCqShorthand(['xs', 'sm', 'md'], '@support (display: flex)')).to.equal(false);
});
it('should return true if the shorthand is a container query', () => {
expect(isCqShorthand(['xs', 'sm', 'md'], '@')).to.equal(true);
expect(isCqShorthand(['xs', 'sm', 'md'], '@xs')).to.equal(true);
expect(isCqShorthand(['xs', 'sm', 'md'], '@xs/sidebar')).to.equal(true);
expect(isCqShorthand(['xs', 'sm', 'md'], '@md')).to.equal(true);
expect(isCqShorthand(['xs', 'sm', 'md'], '@200')).to.equal(true);
expect(isCqShorthand(['xs', 'sm', 'md'], '@15.5rem')).to.equal(true);
});
it('should handle `@` without a breakpoint', () => {
const theme = createTheme();
expect(getContainerQuery(theme, '@')).to.equal('@container (min-width:0px)');
});
it('should have `up`, `down`, `between`, `only`, and `not` functions', () => {
const theme = createTheme();
expect(theme.containerQueries.up('sm')).to.equal('@container (min-width:600px)');
expect(theme.containerQueries.down('sm')).to.equal('@container (max-width:599.95px)');
expect(theme.containerQueries.between('sm', 'md')).to.equal(
'@container (min-width:600px) and (max-width:899.95px)',
);
expect(theme.containerQueries.only('sm')).to.equal(
'@container (min-width:600px) and (max-width:899.95px)',
);
expect(theme.containerQueries.not('xs')).to.equal('@container (min-width:600px)');
expect(theme.containerQueries.not('xl')).to.equal('@container (max-width:1535.95px)');
expect(theme.containerQueries.not('md')).to.equal(
'@container (width<900px) or (width>1199.95px)',
);
});
it('should be able to create named containment context', () => {
const theme = createTheme();
expect(theme.containerQueries('sidebar').up('sm')).to.equal(
'@container sidebar (min-width:600px)',
);
expect(theme.containerQueries('sidebar').down('sm')).to.equal(
'@container sidebar (max-width:599.95px)',
);
expect(theme.containerQueries('sidebar').between('sm', 'md')).to.equal(
'@container sidebar (min-width:600px) and (max-width:899.95px)',
);
expect(theme.containerQueries('sidebar').only('sm')).to.equal(
'@container sidebar (min-width:600px) and (max-width:899.95px)',
);
expect(theme.containerQueries('sidebar').not('xs')).to.equal(
'@container sidebar (min-width:600px)',
);
expect(theme.containerQueries('sidebar').not('xl')).to.equal(
'@container sidebar (max-width:1535.95px)',
);
expect(theme.containerQueries('sidebar').not('sm')).to.equal(
'@container sidebar (width<600px) or (width>899.95px)',
);
});
it('should sort container queries', () => {
const theme = createTheme();
const css = {
'@container (min-width:960px)': {},
'@container (min-width:1280px)': {},
'@container (min-width:0px)': {},
'@container (min-width:600px)': {},
};
const sorted = sortContainerQueries(theme, css);
expect(Object.keys(sorted)).to.deep.equal([
'@container (min-width:0px)',
'@container (min-width:600px)',
'@container (min-width:960px)',
'@container (min-width:1280px)',
]);
});
it('should sort container queries with other unit', () => {
const theme = createTheme();
const css = {
'@container (min-width:30.5rem)': {},
'@container (min-width:20rem)': {},
'@container (min-width:50.5rem)': {},
'@container (min-width:40rem)': {},
};
const sorted = sortContainerQueries(theme, css);
expect(Object.keys(sorted)).to.deep.equal([
'@container (min-width:20rem)',
'@container (min-width:30.5rem)',
'@container (min-width:40rem)',
'@container (min-width:50.5rem)',
]);
});
it('should throw an error if shorthand is invalid', () => {
expect(() => {
const theme = createTheme();
getContainerQuery(theme, 'cq0');
}).to.throw(
'MUI: The provided shorthand (cq0) is invalid. The format should be `@<breakpoint | number>` or `@<breakpoint | number>/<container>`.\n' +
'For example, `@sm` or `@600` or `@40rem/sidebar`.',
);
});
});

View File

@@ -0,0 +1,118 @@
import { Breakpoints, Breakpoint } from '../createBreakpoints/createBreakpoints';
export interface ContainerQueries {
up: Breakpoints['up'];
down: Breakpoints['down'];
between: Breakpoints['between'];
only: Breakpoints['only'];
not: Breakpoints['not'];
}
export interface CssContainerQueries {
containerQueries: ((name: string) => ContainerQueries) & ContainerQueries;
}
/**
* For using in `sx` prop to sort the breakpoint from low to high.
* Note: this function does not work and will not support multiple units.
* e.g. input: { '@container (min-width:300px)': '1rem', '@container (min-width:40rem)': '2rem' }
* output: { '@container (min-width:40rem)': '2rem', '@container (min-width:300px)': '1rem' } // since 40 < 300 even though 40rem > 300px
*/
export function sortContainerQueries(
theme: Partial<CssContainerQueries>,
css: Record<string, any>,
) {
if (!theme.containerQueries) {
return css;
}
const sorted = Object.keys(css)
.filter((key) => key.startsWith('@container'))
.sort((a, b) => {
const regex = /min-width:\s*([0-9.]+)/;
return +(a.match(regex)?.[1] || 0) - +(b.match(regex)?.[1] || 0);
});
if (!sorted.length) {
return css;
}
return sorted.reduce(
(acc, key) => {
const value = css[key];
delete acc[key];
acc[key] = value;
return acc;
},
{ ...css },
);
}
export function isCqShorthand(breakpointKeys: string[], value: string) {
return (
value === '@' ||
(value.startsWith('@') &&
(breakpointKeys.some((key) => value.startsWith(`@${key}`)) || !!value.match(/^@\d/)))
);
}
export function getContainerQuery(theme: CssContainerQueries, shorthand: string) {
const matches = shorthand.match(/^@([^/]+)?\/?(.+)?$/);
if (!matches) {
if (process.env.NODE_ENV !== 'production') {
throw /* minify-error */ new Error(
`MUI: The provided shorthand ${`(${shorthand})`} is invalid. The format should be \`@<breakpoint | number>\` or \`@<breakpoint | number>/<container>\`.\n` +
'For example, `@sm` or `@600` or `@40rem/sidebar`.',
);
}
return null;
}
const [, containerQuery, containerName] = matches;
const value = (Number.isNaN(+containerQuery) ? containerQuery || 0 : +containerQuery) as
| Breakpoint
| number;
return theme.containerQueries(containerName).up(value);
}
export default function cssContainerQueries<T extends { breakpoints: Breakpoints }>(
themeInput: T,
): T & CssContainerQueries {
const toContainerQuery = (mediaQuery: string, name?: string) =>
mediaQuery.replace('@media', name ? `@container ${name}` : '@container');
function attachCq(node: any, name?: string) {
node.up = (...args: Parameters<Breakpoints['up']>) =>
toContainerQuery(themeInput.breakpoints.up(...args), name);
node.down = (...args: Parameters<Breakpoints['down']>) =>
toContainerQuery(themeInput.breakpoints.down(...args), name);
node.between = (...args: Parameters<Breakpoints['between']>) =>
toContainerQuery(themeInput.breakpoints.between(...args), name);
node.only = (...args: Parameters<Breakpoints['only']>) =>
toContainerQuery(themeInput.breakpoints.only(...args), name);
node.not = (...args: Parameters<Breakpoints['not']>) => {
const result = toContainerQuery(themeInput.breakpoints.not(...args), name);
if (result.includes('not all and')) {
// `@container` does not work with `not all and`, so need to invert the logic
return result
.replace('not all and ', '')
.replace('min-width:', 'width<')
.replace('max-width:', 'width>')
.replace('and', 'or');
}
return result;
};
}
const node = {};
const containerQueries = ((name: string) => {
attachCq(node, name);
return node;
}) as CssContainerQueries['containerQueries'];
attachCq(containerQueries);
return {
...themeInput,
containerQueries,
};
}

View File

@@ -0,0 +1,3 @@
export { default } from './cssContainerQueries';
export { isCqShorthand, getContainerQuery, sortContainerQueries } from './cssContainerQueries';
export type { CssContainerQueries } from './cssContainerQueries';

View File

@@ -0,0 +1,33 @@
import { PropsFor, SimpleStyleFunction } from '../style';
export const gap: SimpleStyleFunction<'gap'>;
export const columnGap: SimpleStyleFunction<'columnGap'>;
export const rowGap: SimpleStyleFunction<'rowGap'>;
export const gridColumn: SimpleStyleFunction<'gridColumn'>;
export const gridRow: SimpleStyleFunction<'gridRow'>;
export const gridAutoFlow: SimpleStyleFunction<'gridAutoFlow'>;
export const gridAutoColumns: SimpleStyleFunction<'gridAutoColumns'>;
export const gridAutoRows: SimpleStyleFunction<'gridAutoRows'>;
export const gridTemplateColumns: SimpleStyleFunction<'gridTemplateColumns'>;
export const gridTemplateRows: SimpleStyleFunction<'gridTemplateRows'>;
export const gridTemplateAreas: SimpleStyleFunction<'gridTemplateAreas'>;
export const gridArea: SimpleStyleFunction<'gridArea'>;
declare const grid: SimpleStyleFunction<
| 'gap'
| 'columnGap'
| 'rowGap'
| 'gridColumn'
| 'gridRow'
| 'gridAutoFlow'
| 'gridAutoColumns'
| 'gridAutoRows'
| 'gridTemplateColumns'
| 'gridTemplateRows'
| 'gridTemplateAreas'
| 'gridArea'
>;
export type CssGridProps = PropsFor<typeof grid>;
export default grid;

View File

@@ -0,0 +1,113 @@
import style from '../style';
import compose from '../compose';
import { createUnaryUnit, getValue } from '../spacing';
import { handleBreakpoints } from '../breakpoints';
import responsivePropType from '../responsivePropType';
// false positive
// eslint-disable-next-line react/function-component-definition
export const gap = (props) => {
if (props.gap !== undefined && props.gap !== null) {
const transformer = createUnaryUnit(props.theme, 'spacing', 8, 'gap');
const styleFromPropValue = (propValue) => ({
gap: getValue(transformer, propValue),
});
return handleBreakpoints(props, props.gap, styleFromPropValue);
}
return null;
};
gap.propTypes = process.env.NODE_ENV !== 'production' ? { gap: responsivePropType } : {};
gap.filterProps = ['gap'];
// false positive
// eslint-disable-next-line react/function-component-definition
export const columnGap = (props) => {
if (props.columnGap !== undefined && props.columnGap !== null) {
const transformer = createUnaryUnit(props.theme, 'spacing', 8, 'columnGap');
const styleFromPropValue = (propValue) => ({
columnGap: getValue(transformer, propValue),
});
return handleBreakpoints(props, props.columnGap, styleFromPropValue);
}
return null;
};
columnGap.propTypes =
process.env.NODE_ENV !== 'production' ? { columnGap: responsivePropType } : {};
columnGap.filterProps = ['columnGap'];
// false positive
// eslint-disable-next-line react/function-component-definition
export const rowGap = (props) => {
if (props.rowGap !== undefined && props.rowGap !== null) {
const transformer = createUnaryUnit(props.theme, 'spacing', 8, 'rowGap');
const styleFromPropValue = (propValue) => ({
rowGap: getValue(transformer, propValue),
});
return handleBreakpoints(props, props.rowGap, styleFromPropValue);
}
return null;
};
rowGap.propTypes = process.env.NODE_ENV !== 'production' ? { rowGap: responsivePropType } : {};
rowGap.filterProps = ['rowGap'];
export const gridColumn = style({
prop: 'gridColumn',
});
export const gridRow = style({
prop: 'gridRow',
});
export const gridAutoFlow = style({
prop: 'gridAutoFlow',
});
export const gridAutoColumns = style({
prop: 'gridAutoColumns',
});
export const gridAutoRows = style({
prop: 'gridAutoRows',
});
export const gridTemplateColumns = style({
prop: 'gridTemplateColumns',
});
export const gridTemplateRows = style({
prop: 'gridTemplateRows',
});
export const gridTemplateAreas = style({
prop: 'gridTemplateAreas',
});
export const gridArea = style({
prop: 'gridArea',
});
const grid = compose(
gap,
columnGap,
rowGap,
gridColumn,
gridRow,
gridAutoFlow,
gridAutoColumns,
gridAutoRows,
gridTemplateColumns,
gridTemplateRows,
gridTemplateAreas,
gridArea,
);
export default grid;

View File

@@ -0,0 +1,61 @@
import { expect } from 'chai';
import grid from './cssGrid';
describe('grid', () => {
it('should use the spacing unit', () => {
const output = grid({
gap: 1,
});
expect(output).to.deep.equal({
gap: 8,
});
});
it('should accept 0', () => {
const output = grid({
gap: 0,
columnGap: 0,
rowGap: 0,
});
expect(output).to.deep.equal({
gap: 0,
columnGap: 0,
rowGap: 0,
});
});
it('should support breakpoints', () => {
const output = grid({
gap: [1, 2],
});
expect(output).to.deep.equal({
'@media (min-width:0px)': {
gap: 8,
},
'@media (min-width:600px)': {
gap: 16,
},
});
});
it('should support container queries', () => {
const output1 = grid({
gap: {
'@sm': 1,
'@900/sidebar': 2,
'@80rem/sidebar': 3,
},
});
expect(output1).to.deep.equal({
'@container (min-width:600px)': {
gap: 8,
},
'@container sidebar (min-width:900px)': {
gap: 16,
},
'@container sidebar (min-width:80rem)': {
gap: 24,
},
});
});
});

View File

@@ -0,0 +1,2 @@
export { default } from './cssGrid';
export * from './cssGrid';

Some files were not shown because too many files have changed in this diff Show More