diff --git a/packages/mui-material-next/src/Button/Button.tsx b/packages/mui-material-next/src/Button/Button.tsx index bc6394f4e9e7fe..938c708524f6c8 100644 --- a/packages/mui-material-next/src/Button/Button.tsx +++ b/packages/mui-material-next/src/Button/Button.tsx @@ -306,11 +306,10 @@ export const ButtonRoot = styled('button', { padding: '10px 24px', minWidth: 64, letterSpacing, - // Taken from MD2, haven't really found a spec on transitions - transition: theme.transitions.create( + transition: theme.sys.motion.create( ['background-color', 'box-shadow', 'border-color', 'color'], { - duration: theme.transitions.duration.short, + duration: tokens.sys.motion.duration.short3, }, ), fontFamily: tokens.sys.typescale.label.large.family, diff --git a/packages/mui-material-next/src/styles/Theme.types.ts b/packages/mui-material-next/src/styles/Theme.types.ts index 3c0a16c371edb9..8b16651acd7904 100644 --- a/packages/mui-material-next/src/styles/Theme.types.ts +++ b/packages/mui-material-next/src/styles/Theme.types.ts @@ -149,6 +149,57 @@ export interface MD3ShapeOptions { corner?: Partial; } +export interface MD3Easing { + linear: string; + standard: string; + standardAccelerate: string; + standardDecelerate: string; + emphasized: string; + emphasizedDecelerate: string; + emphasizedAccelerate: string; + legacy: string; + legacyDecelerate: string; + legacyAccelerate: string; +} +export interface MD3Duration { + short1: string; + short2: string; + short3: string; + short4: string; + medium1: string; + medium2: string; + medium3: string; + medium4: string; + long1: string; + long2: string; + long3: string; + long4: string; + extraLong1: string; + extraLong2: string; + extraLong3: string; + extraLong4: string; +} + +export interface MotionOptions { + easing?: Partial; + duration?: Partial; + create?: ( + props: string | string[], + options?: Partial<{ duration: number | string; easing: string; delay: number | string }>, + ) => string; + getAutoHeightDuration?: (height: number) => number; +} + +export interface Motion { + easing: MD3Easing; + duration: MD3Duration; + create: ( + props: string | string[], + options?: Partial<{ duration: number | string; easing: string; delay: number | string }>, + ) => string; + getAutoHeightDuration: (height: number) => number; +} + export interface MD3CssVarsThemeOptions extends Omit { ref?: { typeface?: Partial; @@ -157,6 +208,7 @@ export interface MD3CssVarsThemeOptions extends Omit; state?: Partial; elevation?: string[]; + motion?: MotionOptions; shape?: MD3ShapeOptions; }; } @@ -189,6 +241,7 @@ export interface Theme extends Omit { typescale: MD3Typescale; state: MD3State; elevation: string[]; + motion: Motion; shape: MD3Shape; }; palette: MD2Theme['palette']; @@ -203,6 +256,7 @@ export interface Theme extends Omit { typescale: MD3Typescale; state: MD3State; elevation: string[]; + motion: Omit; shape: MD3Shape; }; }; diff --git a/packages/mui-material-next/src/styles/extendTheme.ts b/packages/mui-material-next/src/styles/extendTheme.ts index 460c461c1c084f..0b58148f721dd0 100644 --- a/packages/mui-material-next/src/styles/extendTheme.ts +++ b/packages/mui-material-next/src/styles/extendTheme.ts @@ -25,6 +25,7 @@ import md3Typescale from './typescale'; import md3Typeface from './typeface'; import md3State from './state'; import { elevationLight, elevationDark } from './elevation'; +import createMotions from './motion'; import md3shape from './shape'; const defaultLightOverlays: Overlays = [...Array(25)].map(() => undefined) as Overlays; @@ -62,6 +63,11 @@ export default function extendTheme(options: CssVarsThemeOptions = {}, ...args: corner: { ...input.sys?.shape?.corner, ...md3shape.corner }, }; + const motion = createMotions(input.sys?.motion); + const typescale = { ...md3Typescale, ...input.sys?.typescale }; + const typeface = { ...md3Typeface, ...input.ref?.typeface }; + const state = { ...md3State, ...input.sys?.state }; + const { palette: lightPalette, // @ts-ignore - sys is md3 specific token @@ -76,13 +82,14 @@ export default function extendTheme(options: CssVarsThemeOptions = {}, ...args: useMaterialYou: true, ref: { ...input.ref, - typeface: { ...md3Typeface, ...input.ref?.typeface }, + typeface, palette: deepmerge(md3CommonPalette, colorSchemesInput.light?.ref?.palette), }, sys: { ...input.sys, - typescale: { ...md3Typescale, ...input.sys?.typescale }, - state: { ...md3State, ...input.sys?.state }, + typescale, + state, + motion, color: { ...md3LightColors, ...colorSchemesInput.light?.sys?.color }, elevation: colorSchemesInput.light?.sys?.elevation ?? elevationLight, shape, @@ -106,13 +113,14 @@ export default function extendTheme(options: CssVarsThemeOptions = {}, ...args: // @ts-ignore - it's fine, everything that is not supported will be spread ref: { ...input.ref, - typeface: { ...md3Typeface, ...input.ref?.typeface }, + typeface, palette: deepmerge(md3CommonPalette, colorSchemesInput.dark?.ref?.palette), }, sys: { ...input.sys, - typescale: { ...md3Typescale, ...input.sys?.typescale }, - state: { ...md3State, ...input.sys?.state }, + typescale, + state, + motion, color: { ...md3DarkColors, ...colorSchemesInput.dark?.sys?.color }, elevation: colorSchemesInput.dark?.sys?.elevation ?? elevationDark, shape, diff --git a/packages/mui-material-next/src/styles/motion.test.js b/packages/mui-material-next/src/styles/motion.test.js new file mode 100644 index 00000000000000..d62934285bc8d2 --- /dev/null +++ b/packages/mui-material-next/src/styles/motion.test.js @@ -0,0 +1,156 @@ +import { expect } from 'chai'; +import { extendTheme } from '@mui/material-next/styles'; +import createMotion, { easing, duration } from './motion'; + +describe('motion', () => { + const motion = createMotion({}); + const create = motion.create; + const getAutoHeightDuration = motion.getAutoHeightDuration; + + it('should allow to customize the default duration', () => { + const theme = extendTheme({ + sys: { + motion: { + duration: { + medium1: '310ms', + }, + }, + }, + }); + expect(theme.sys.motion.create('color')).to.equal(`color 310ms ${easing.standard} 0ms`); + }); + + describe('create() function', () => { + describe('warnings', () => { + it('should warn when first argument is of bad type', () => { + expect(() => create(5554)).toErrorDev('MUI: Argument "props" must be a string or Array'); + expect(() => create({})).toErrorDev('MUI: Argument "props" must be a string or Array'); + }); + + it('should warn when bad "duration" option type', () => { + expect(() => create('font', { duration: null })).toErrorDev( + 'MUI: Argument "duration" must be a number or a string but found null', + ); + expect(() => create('font', { duration: {} })).toErrorDev( + 'MUI: Argument "duration" must be a number or a string but found [object Object]', + ); + }); + + it('should warn when bad "easing" option type', () => { + expect(() => create('transform', { easing: 123 })).toErrorDev( + 'MUI: Argument "easing" must be a string', + ); + expect(() => create('transform', { easing: {} })).toErrorDev( + 'MUI: Argument "easing" must be a string', + ); + }); + + it('should warn when bad "delay" option type', () => { + expect(() => create('size', { delay: null })).toErrorDev( + 'MUI: Argument "delay" must be a number or a string', + ); + expect(() => create('size', { delay: {} })).toErrorDev( + 'MUI: Argument "delay" must be a number or a string', + ); + }); + + it('should warn when passed unrecognized option', () => { + expect(() => create('size', { fffds: 'value' })).toErrorDev( + 'MUI: Unrecognized argument(s) [fffds]', + ); + }); + }); + + it('should create default transition without arguments', () => { + const transition = create(); + expect(transition).to.equal(`all ${duration.medium1} ${easing.standard} 0ms`); + }); + + it('should take string props as a first argument', () => { + const transition = create('color'); + expect(transition).to.equal(`color ${duration.medium1} ${easing.standard} 0ms`); + }); + + it('should also take array of props as first argument', () => { + const options = { delay: 20 }; + const multiple = create(['color', 'size'], options); + const single1 = create('color', options); + const single2 = create('size', options); + const expected = `${single1},${single2}`; + expect(multiple).to.equal(expected); + }); + + it('should optionally accept number "duration" option in second argument', () => { + const transition = create('font', { duration: 500 }); + expect(transition).to.equal(`font 500ms ${easing.standard} 0ms`); + }); + + it('should optionally accept string "duration" option in second argument', () => { + const transition = create('font', { duration: '500ms' }); + expect(transition).to.equal(`font 500ms ${easing.standard} 0ms`); + }); + + it('should round decimal digits of "duration" prop to whole numbers', () => { + const transition = create('font', { duration: 12.125 }); + expect(transition).to.equal(`font 12ms ${easing.standard} 0ms`); + }); + + it('should optionally accept string "easing" option in second argument', () => { + const transition = create('transform', { easing: easing.linear }); + expect(transition).to.equal(`transform ${duration.medium1} ${easing.linear} 0ms`); + }); + + it('should optionally accept number "delay" option in second argument', () => { + const transition = create('size', { delay: 150 }); + expect(transition).to.equal(`size ${duration.medium1} ${easing.standard} 150ms`); + }); + + it('should optionally accept string "delay" option in second argument', () => { + const transition = create('size', { delay: '150ms' }); + expect(transition).to.equal(`size ${duration.medium1} ${easing.standard} 150ms`); + }); + + it('should round decimal digits of "delay" prop to whole numbers', () => { + const transition = create('size', { delay: 1.547 }); + expect(transition).to.equal(`size ${duration.medium1} ${easing.standard} 2ms`); + }); + + it('should return zero when not passed arguments', () => { + const zeroHeightDuration = getAutoHeightDuration(); + expect(zeroHeightDuration).to.equal(0); + }); + + it('should return zero when passed undefined', () => { + const zeroHeightDuration = getAutoHeightDuration(undefined); + expect(zeroHeightDuration).to.equal(0); + }); + + it('should return zero when passed null', () => { + const zeroHeightDuration = getAutoHeightDuration(null); + expect(zeroHeightDuration).to.equal(0); + }); + + it('should return NaN when passed a negative number', () => { + const zeroHeightDurationNegativeOne = getAutoHeightDuration(-1); + // eslint-disable-next-line no-restricted-globals + expect(isNaN(zeroHeightDurationNegativeOne)).to.equal(true); + const zeroHeightDurationSmallNegative = getAutoHeightDuration(-0.000001); + // eslint-disable-next-line no-restricted-globals + expect(isNaN(zeroHeightDurationSmallNegative)).to.equal(true); + const zeroHeightDurationBigNegative = getAutoHeightDuration(-100000); + // eslint-disable-next-line no-restricted-globals + expect(isNaN(zeroHeightDurationBigNegative)).to.equal(true); + }); + + it('should return values for pre-calculated positive examples', () => { + let zeroHeightDuration = getAutoHeightDuration(14); + expect(zeroHeightDuration).to.equal(159); + zeroHeightDuration = getAutoHeightDuration(100); + expect(zeroHeightDuration).to.equal(239); + zeroHeightDuration = getAutoHeightDuration(0.0001); + expect(zeroHeightDuration).to.equal(46); + zeroHeightDuration = getAutoHeightDuration(100000); + expect(zeroHeightDuration).to.equal(6685); + }); + }); +}); diff --git a/packages/mui-material-next/src/styles/motion.ts b/packages/mui-material-next/src/styles/motion.ts new file mode 100644 index 00000000000000..8e8f0a09855f58 --- /dev/null +++ b/packages/mui-material-next/src/styles/motion.ts @@ -0,0 +1,118 @@ +import { MD3Duration, MD3Easing, MotionOptions } from './Theme.types'; + +// Follows https://m3.material.io/styles/motion/easing-and-duration/tokens-specs +export const duration: MD3Duration = { + short1: '50ms', + short2: '100ms', + short3: '150ms', + short4: '200ms', + medium1: '250ms', + medium2: '300ms', + medium3: '350ms', + medium4: '400ms', + long1: '450ms', + long2: '500ms', + long3: '550ms', + long4: '600ms', + extraLong1: '700ms', + extraLong2: '800ms', + extraLong3: '900ms', + extraLong4: '1000ms', +}; + +export const easing: MD3Easing = { + linear: 'cubic-bezier(0, 0, 1, 1)', + standard: 'cubic-bezier(0.2, 0, 0, 1)', + standardAccelerate: 'cubic-bezier(0.3, 0, 1, 1)', + standardDecelerate: 'cubic-bezier(0, 0, 0, 1)', + emphasized: 'cubic-bezier(0.2, 0, 0, 1)', + emphasizedDecelerate: 'cubic-bezier(0.05, 0.7, 0.1, 1)', + emphasizedAccelerate: 'cubic-bezier(0.3, 0, 0.8, 0.15)', + legacy: 'cubic-bezier(0.4, 0, 0.2, 1)', + legacyDecelerate: 'cubic-bezier(0.0, 0, 0.2, 1)', + legacyAccelerate: 'cubic-bezier(0.4, 0, 1.0, 1)', +}; + +function formatMs(milliseconds: number) { + return `${Math.round(milliseconds)}ms`; +} + +function getAutoHeightDuration(height: number) { + if (!height) { + return 0; + } + + const constant = height / 36; + + // https://www.wolframalpha.com/input/?i=(4+%2B+15+*+(x+%2F+36+)+**+0.25+%2B+(x+%2F+36)+%2F+5)+*+10 + return Math.round((4 + 15 * constant ** 0.25 + constant / 5) * 10); +} + +export default function createMotions(inputMotion: MotionOptions = {}) { + const mergedEasing = { + ...easing, + ...inputMotion.easing, + }; + + const mergedDuration = { + ...duration, + ...inputMotion.duration, + }; + + const create = ( + props = ['all'], + options: { duration?: number | string; easing?: string | string; delay?: number | string } = {}, + ) => { + const { + duration: durationOption = mergedDuration.medium1, + easing: easingOption = mergedEasing.standard, + delay = 0, + ...other + } = options; + + if (process.env.NODE_ENV !== 'production') { + const isString = (value: any) => typeof value === 'string'; + // IE11 support, replace with Number.isNaN + // eslint-disable-next-line no-restricted-globals + const isNumber = (value: any) => !isNaN(parseFloat(value)); + if (!isString(props) && !Array.isArray(props)) { + console.error('MUI: Argument "props" must be a string or Array.'); + } + + if (!isNumber(durationOption) && !isString(durationOption)) { + console.error( + `MUI: Argument "duration" must be a number or a string but found ${durationOption}.`, + ); + } + + if (!isString(easingOption)) { + console.error('MUI: Argument "easing" must be a string.'); + } + + if (!isNumber(delay) && !isString(delay)) { + console.error('MUI: Argument "delay" must be a number or a string.'); + } + + if (Object.keys(other).length !== 0) { + console.error(`MUI: Unrecognized argument(s) [${Object.keys(other).join(',')}].`); + } + } + + return (Array.isArray(props) ? props : [props]) + .map( + (animatedProp) => + `${animatedProp} ${ + typeof durationOption === 'string' ? durationOption : formatMs(durationOption) + } ${easingOption} ${typeof delay === 'string' ? delay : formatMs(delay)}`, + ) + .join(','); + }; + + return { + getAutoHeightDuration, + create, + ...inputMotion, + easing: mergedEasing, + duration: mergedDuration, + }; +}