diff --git a/docs/src/pages/guides/typescript/typescript.md b/docs/src/pages/guides/typescript/typescript.md index 46e6c592418376..94d83924700f3d 100644 --- a/docs/src/pages/guides/typescript/typescript.md +++ b/docs/src/pages/guides/typescript/typescript.md @@ -5,45 +5,132 @@ Have a look at the [Create React App with TypeScript](https://github.com/mui-org ## Usage of `withStyles` -The usage of `withStyles` in TypeScript can be a little tricky, so it's worth showing some examples. You can first call `withStyles()` to create a decorator function, like so: +Using `withStyles` in TypeScript can be a little tricky, but there are some utilities to make the experience as painless as possible. -```js -const decorate = withStyles(({ palette, spacing }) => ({ +### Using `createStyles` to defeat type widening + +A frequent source of confusion is TypeScript's [type widening](https://blog.mariusschulz.com/2017/02/04/typescript-2-1-literal-type-widening), which causes this example not to work as expected: + +```ts +const styles = { + root: { + display: 'flex', + flexDirection: 'column', + } +}; + +withStyles(styles); +// ^^^^^^ +// Types of property 'flexDirection' are incompatible. +// Type 'string' is not assignable to type '"-moz-initial" | "inherit" | "initial" | "revert" | "unset" | "column" | "column-reverse" | "row"...'. +``` + +The problem is that the type of the `flexDirection` property is inferred as `string`, which is too arbitrary. To fix this, you can pass the styles object directly to `withStyles`: + +```ts +withStyles({ + root: { + display: 'flex', + flexDirection: 'column', + }, +}); +``` + +However type widening rears its ugly head once more if you try to make the styles depend on the theme: + +```ts +withStyles(({ palette, spacing }) => ({ root: { + display: 'flex', + flexDirection: 'column', padding: spacing.unit, backgroundColor: palette.background.default, - color: palette.primary.main + color: palette.primary.main, }, })); ``` -This can then subsequently be used to decorate either a stateless functional component or a class component. Suppose we have in either case the following props: +This is because TypeScript [widens the return types of function expressions](https://github.com/Microsoft/TypeScript/issues/241). + +Because of this, we recommend using our `createStyles` helper function to construct your style rules object: + +```ts +// Non-dependent styles +const styles = createStyles({ + root: { + display: 'flex', + flexDirection: 'column', + }, +}); + +// Theme-dependent styles +const styles = ({ palette, spacing }: Theme) => createStyles({ + root: { + display: 'flex', + flexDirection: 'column', + padding: spacing.unit, + backgroundColor: palette.background.default, + color: palette.primary.main, + }, +}); +``` + +`createStyles` is just the identity function; it doesn't "do anything" at runtime, just helps guide type inference at compile time. + +### Augmenting your props using `WithStyles` + +Since a component decorated with `withStyles(styles)` gets a special `classes` prop injected, you will want to define its props accordingly: + +```ts +const styles = (theme: Theme) => createStyles({ + root: { /* ... */ }, + paper: { /* ... */ }, + button: { /* ... */ }, +}); -```js interface Props { - text: string; - type: TypographyProps['type']; - color: TypographyProps['color']; -}; + // non-style props + foo: number; + bar: boolean; + // injected style props + classes: { + root: string; + paper: string; + button: string; + }; +} +``` + +However this isn't very [DRY](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself) because it requires you to maintain the class names (`'root'`, `'paper'`, `'button'`, ...) in two different places. We provide a type operator `WithStyles` to help with this, so that you can just write + +```ts +import { WithStyles } from '@material-ui/core'; + +const styles = (theme: Theme) => createStyles({ + root: { /* ... */ }, + paper: { /* ... */ }, + button: { /* ... */ }, +}); + +interface Props extends WithStyles { + foo: number; + bar: boolean; +} ``` -Functional components are straightforward: +### Decorating components + +Applying the `withStyles(styles)` decorator as a function works as expected: -```jsx -const DecoratedSFC = decorate(({ text, type, color, classes }) => ( +```tsx +const DecoratedSFC = withStyles(styles)(({ text, type, color, classes }: Props) => ( {text} )); -``` - -Class components are a little more cumbersome. Due to a [current limitation in TypeScript's decorator support](https://github.com/Microsoft/TypeScript/issues/4881), `withStyles` can't be used as a class decorator. Instead, we decorate a class component like so: -```jsx -import { WithStyles } from '@material-ui/core/styles'; - -const DecoratedClass = decorate( - class extends React.Component> { +const DecoratedClass = withStyles(styles)( + class extends React.Component { render() { const { text, type, color, classes } = this.props return ( @@ -56,11 +143,13 @@ const DecoratedClass = decorate( ); ``` -When your `props` are a union, Typescript needs you to explicitly tell it the type, by providing a generic `` parameter to `decorate`: +Unfortunately due to a [current limitation of TypeScript decorators](https://github.com/Microsoft/TypeScript/issues/4881), `withStyles` can't be used as a decorator in TypeScript. -```jsx -import { WithStyles } from '@material-ui/core/styles'; +### Union props + +When your `props` are a union, Typescript needs you to explicitly tell it the type, by providing a generic `` parameter to `decorate`: +```tsx interface Book { category: "book"; author: string; @@ -71,10 +160,12 @@ interface Painting { artist: string; } -type Props = Book | Painting; +type BookOrPainting = Book | Painting; -const DecoratedUnionProps = decorate( // <-- without the type argument, we'd get a compiler error! - class extends React.Component> { +type Props = BookOrPainting & WithStyles; + +const DecoratedUnionProps = withStyles(styles)( // <-- without the type argument, we'd get a compiler error! + class extends React.Component { render() { const props = this.props; return ( @@ -87,43 +178,6 @@ const DecoratedUnionProps = decorate( // <-- without the type argument, w ); ``` - -### Injecting Multiple Classes - -Injecting multiple classes into a component is as straightforward as possible. Take the following code for example. The classes `one` and `two` are both available with type information on the `classes`-prop passed in by `withStyles`. - -```jsx -import { Theme, withStyles, WithStyles } from "material-ui/styles"; -import * as React from "react"; - -const styles = (theme: Theme) => ({ - one: { - backgroundColor: "red", - }, - two: { - backgroundColor: "pink", - }, -}); - -type Props = { - someProp: string; -}; - -type PropsWithStyles = Props & WithStyles>; - -const Component: React.SFC = ({ - classes, - ...props -}: PropsWithStyles) => ( -
-
One
-
Two
-
-); - -export default withStyles(styles)(Component); -``` - ## Customization of `Theme` When adding custom properties to the `Theme`, you may continue to use it in a strongly typed way by exploiting @@ -131,7 +185,7 @@ When adding custom properties to the `Theme`, you may continue to use it in a st The following example adds an `appDrawer` property that is merged into the one exported by `material-ui`: -```js +```ts import { Theme } from '@material-ui/core/styles/createMuiTheme'; import { Breakpoint } from '@material-ui/core/styles/createBreakpoints'; @@ -154,7 +208,7 @@ declare module '@material-ui/core/styles/createMuiTheme' { And a custom theme factory with additional defaulted options: -```js +```ts import createMuiTheme, { ThemeOptions } from '@material-ui/core/styles/createMuiTheme'; export default function createMyTheme(options: ThemeOptions) { @@ -170,7 +224,7 @@ export default function createMyTheme(options: ThemeOptions) { This could be used like: -```js +```ts import createMyTheme from './styles/createMyTheme'; const theme = createMyTheme({ appDrawer: { breakpoint: 'md' }}); diff --git a/packages/material-ui/src/index.d.ts b/packages/material-ui/src/index.d.ts index 83871dbabc2ce4..34e8abd6fc864e 100644 --- a/packages/material-ui/src/index.d.ts +++ b/packages/material-ui/src/index.d.ts @@ -75,6 +75,7 @@ export { Theme, withStyles, WithStyles, + createStyles, withTheme, WithTheme, } from './styles'; diff --git a/packages/material-ui/src/index.js b/packages/material-ui/src/index.js index e2a02e5c535cbf..cfe9da4d20e39a 100644 --- a/packages/material-ui/src/index.js +++ b/packages/material-ui/src/index.js @@ -6,6 +6,7 @@ export { colors }; export { createGenerateClassName, createMuiTheme, + createStyles, jssPreset, MuiThemeProvider, withStyles, diff --git a/packages/material-ui/src/styles/createStyles.d.ts b/packages/material-ui/src/styles/createStyles.d.ts new file mode 100644 index 00000000000000..983fbfe5569fd5 --- /dev/null +++ b/packages/material-ui/src/styles/createStyles.d.ts @@ -0,0 +1,11 @@ +import { CSSProperties, StyleRules } from './withStyles'; + +/** + * This function doesn't really "do anything" at runtime, it's just the identity + * function. Its only purpose is to defeat TypeScript's type widening when providing + * style rules to `withStyles` which are a function of the `Theme`. + * + * @param styles a set of style mappings + * @returns the same styles that were passed in + */ +export default function createStyles(styles: S): S; diff --git a/packages/material-ui/src/styles/createStyles.js b/packages/material-ui/src/styles/createStyles.js new file mode 100644 index 00000000000000..adbb7b02637606 --- /dev/null +++ b/packages/material-ui/src/styles/createStyles.js @@ -0,0 +1,5 @@ +// @flow + +export default function createStyles(s: Object) { + return s; +} diff --git a/packages/material-ui/src/styles/createStyles.test.js b/packages/material-ui/src/styles/createStyles.test.js new file mode 100644 index 00000000000000..d91cd017e1888c --- /dev/null +++ b/packages/material-ui/src/styles/createStyles.test.js @@ -0,0 +1,9 @@ +import { assert } from 'chai'; +import { createStyles } from '.'; + +describe('createStyles', () => { + it('is the identity function', () => { + const styles = {}; + assert.strictEqual(createStyles(styles), styles); + }); +}); diff --git a/packages/material-ui/src/styles/index.d.ts b/packages/material-ui/src/styles/index.d.ts index 18053e94055c7e..9e9de75d02f0cd 100644 --- a/packages/material-ui/src/styles/index.d.ts +++ b/packages/material-ui/src/styles/index.d.ts @@ -2,6 +2,7 @@ export { default as createGenerateClassName } from './createGenerateClassName'; export { default as createMuiTheme, Theme, Direction } from './createMuiTheme'; export { default as jssPreset } from './jssPreset'; export { default as MuiThemeProvider } from './MuiThemeProvider'; +export { default as createStyles } from './createStyles'; export { default as withStyles, WithStyles, diff --git a/packages/material-ui/src/styles/index.js b/packages/material-ui/src/styles/index.js index c3822d9932c485..4f795f608250dd 100644 --- a/packages/material-ui/src/styles/index.js +++ b/packages/material-ui/src/styles/index.js @@ -2,5 +2,6 @@ export { default as createGenerateClassName } from './createGenerateClassName'; export { default as createMuiTheme } from './createMuiTheme'; export { default as jssPreset } from './jssPreset'; export { default as MuiThemeProvider } from './MuiThemeProvider'; +export { default as createStyles } from './createStyles'; export { default as withStyles } from './withStyles'; export { default as withTheme } from './withTheme'; diff --git a/packages/material-ui/src/styles/withStyles.d.ts b/packages/material-ui/src/styles/withStyles.d.ts index d29708b5355f9c..995ed8f92a5c3c 100644 --- a/packages/material-ui/src/styles/withStyles.d.ts +++ b/packages/material-ui/src/styles/withStyles.d.ts @@ -37,9 +37,14 @@ export interface WithStylesOptions extends JSS export type ClassNameMap = Record; -export interface WithStyles extends Partial { - classes: ClassNameMap; -} +export type WithStyles = Partial & { + classes: ClassNameMap< + T extends string ? T : + T extends StyleRulesCallback ? K : + T extends StyleRules ? K : + never + >; +}; export interface StyledComponentProps { classes?: Partial>; @@ -47,7 +52,7 @@ export interface StyledComponentProps { } export default function withStyles( - style: StyleRules | StyleRulesCallback, + style: StyleRulesCallback | StyleRules, options?: WithStylesOptions, ): {

>>( diff --git a/packages/material-ui/src/withWidth/withWidth.spec.tsx b/packages/material-ui/src/withWidth/withWidth.spec.tsx index 38c76ec693c6ed..3ed75491a498cc 100644 --- a/packages/material-ui/src/withWidth/withWidth.spec.tsx +++ b/packages/material-ui/src/withWidth/withWidth.spec.tsx @@ -1,20 +1,22 @@ import * as React from 'react'; import { Grid } from '..'; -import { Theme } from '../styles'; +import { Theme, createStyles } from '../styles'; import withStyles, { WithStyles } from '../styles/withStyles'; import withWidth, { WithWidthProps } from '../withWidth'; -const styles = (theme: Theme) => ({ +const styles = (theme: Theme) => createStyles({ root: { + display: 'flex', + flexDirection: 'column', backgroundColor: theme.palette.common.black, }, }); -interface IHelloProps { +interface IHelloProps extends WithWidthProps, WithStyles { name?: string; } -export class Hello extends React.Component> { +export class Hello extends React.Component { public static defaultProps = { name: 'Alex', }; diff --git a/packages/material-ui/test/typescript/components.spec.tsx b/packages/material-ui/test/typescript/components.spec.tsx index 64f501e009a336..988b62a170432f 100644 --- a/packages/material-ui/test/typescript/components.spec.tsx +++ b/packages/material-ui/test/typescript/components.spec.tsx @@ -65,9 +65,8 @@ import { Tooltip, Typography, withMobileDialog, - WithStyles, } from '../../src'; -import { withStyles, StyleRulesCallback } from '../../src/styles'; +import { withStyles, StyleRulesCallback, WithStyles, Theme, createStyles } from '../../src/styles'; import { DialogProps } from '../../src/Dialog'; const log = console.log; @@ -663,16 +662,16 @@ const StepperTest = () => }; const TableTest = () => { - const styles: StyleRulesCallback<'paper'> = theme => { + const styles = (theme: Theme) => { const backgroundColor: string = theme.palette.secondary.light; - return { + return createStyles({ paper: { width: '100%', marginTop: theme.spacing.unit * 3, backgroundColor, overflowX: 'auto', }, - }; + }); }; let id = 0; @@ -689,7 +688,7 @@ const TableTest = () => { createData('Gingerbread', 356, 16.0, 49, 3.9), ]; - function BasicTable(props: WithStyles<'paper'>) { + function BasicTable(props: WithStyles) { const classes = props.classes; return ( @@ -751,7 +750,7 @@ const TabsTest = () => { }, }); - class BasicTabs extends React.Component> { + class BasicTabs extends React.Component> { state = { value: 0, }; diff --git a/packages/material-ui/test/typescript/styles.spec.tsx b/packages/material-ui/test/typescript/styles.spec.tsx index bc9957ff14dad6..b088a3118f1aaf 100644 --- a/packages/material-ui/test/typescript/styles.spec.tsx +++ b/packages/material-ui/test/typescript/styles.spec.tsx @@ -1,28 +1,29 @@ import * as React from 'react'; import { + createStyles, withStyles, - WithStyles, createMuiTheme, MuiThemeProvider, Theme, withTheme, StyleRules, + StyleRulesCallback, + StyledComponentProps, + WithStyles, } from '../../src/styles'; import Button from '../../src/Button/Button'; -import { StyleRulesCallback, StyledComponentProps } from '../../src/styles/withStyles'; import blue from '../../src/colors/blue'; import { WithTheme } from '../../src/styles/withTheme'; import { StandardProps } from '../../src'; import { TypographyStyle } from '../../src/styles/createTypography'; // Shared types for examples -type ComponentClassNames = 'root'; interface ComponentProps { text: string; } // Example 1 -const styles: StyleRulesCallback<'root'> = ({ palette, spacing }) => ({ +const styles = ({ palette, spacing }: Theme) => ({ root: { padding: spacing.unit, backgroundColor: palette.background.default, @@ -36,7 +37,7 @@ const StyledExampleOne = withStyles(styles)(({ classes, text }) ; // Example 2 -const Component: React.SFC> = ({ +const Component: React.SFC> = ({ classes, text, }) =>

{text}
; @@ -45,16 +46,16 @@ const StyledExampleTwo = withStyles(styles)(Component); ; // Example 3 -const styleRule: StyleRules = { +const styleRule = createStyles({ root: { display: 'flex', alignItems: 'stretch', height: '100vh', width: '100%', }, -}; +}); -const ComponentWithChildren: React.SFC> = ({ +const ComponentWithChildren: React.SFC> = ({ classes, children, }) =>
{children}
; @@ -166,7 +167,7 @@ const ComponentWithTheme = withTheme()(({ theme }) =>
{theme.spacing.unit}< ; // withStyles + withTheme -type AllTheProps = WithTheme & WithStyles<'root'>; +type AllTheProps = WithTheme & WithStyles; const AllTheComposition = withTheme()( withStyles(styles)(({ theme, classes }: AllTheProps) => ( @@ -180,7 +181,7 @@ const AllTheComposition = withTheme()( // due to https://github.com/Microsoft/TypeScript/issues/4881 //@withStyles(styles) const DecoratedComponent = withStyles(styles)( - class extends React.Component> { + class extends React.Component> { render() { const { classes, text } = this.props; return
{text}
; @@ -192,7 +193,7 @@ const DecoratedComponent = withStyles(styles)( ; // Allow nested pseudo selectors -withStyles<'listItem' | 'guttered'>(theme => ({ +withStyles(theme => createStyles({ guttered: theme.mixins.gutters({ '&:hover': { textDecoration: 'none', @@ -206,49 +207,44 @@ withStyles<'listItem' | 'guttered'>(theme => ({ })); { - type ListItemContentClassKey = 'root' | 'iiiinset' | 'row'; - const styles = withStyles( - theme => ({ - // Styled similar to ListItemText - root: { - '&:first-child': { - paddingLeft: 0, - }, - flex: '1 1 auto', - padding: '0 16px', + const styles = (theme: Theme) => createStyles({ + // Styled similar to ListItemText + root: { + '&:first-child': { + paddingLeft: 0, }, + flex: '1 1 auto', + padding: '0 16px', + }, - iiiinset: { - '&:first-child': { - paddingLeft: theme.spacing.unit * 7, - }, - }, - row: { - alignItems: 'center', - display: 'flex', - flexDirection: 'row', + iiiinset: { + '&:first-child': { + paddingLeft: theme.spacing.unit * 7, }, - }), - { name: 'ui-ListItemContent' }, - ); + }, + row: { + alignItems: 'center', + display: 'flex', + flexDirection: 'row', + }, + }); - interface ListItemContentProps extends StyledComponentProps { + interface ListItemContentProps extends WithStyles { inset?: boolean; row?: boolean; } - const ListItemContent = styles(props => { - const { children, classes, inset, row } = props; - return ( -
+ const ListItemContent = withStyles(styles, { name: 'ui-ListItemContent' })( + ({ children, classes, inset, row }) => ( +
{children}
- ); - }); + ) + ); } { - interface FooProps extends StyledComponentProps<'x' | 'y'> { + interface FooProps extends WithStyles<'x' | 'y'> { a: number; b: boolean; } @@ -261,27 +257,22 @@ withStyles<'listItem' | 'guttered'>(theme => ({ // The real test here is with "strictFunctionTypes": false, // but we don't have a way currently to test under varying // TypeScript configurations. - interface IStyle { - content: any; - } - interface IComponentProps { + interface ComponentProps extends WithStyles { caption: string; } - type ComponentProps = IComponentProps & WithStyles<'content'>; - - const decorate = withStyles((theme): IStyle => ({ + const styles = (theme: Theme) => createStyles({ content: { margin: 4, }, - })); + }); const Component = (props: ComponentProps) => { return
Hello {props.caption}
; }; - const StyledComponent = decorate(Component); + const StyledComponent = withStyles(styles)(Component); class App extends React.Component { public render() { @@ -298,23 +289,21 @@ withStyles<'listItem' | 'guttered'>(theme => ({ { // https://github.com/mui-org/material-ui/issues/11191 - const decorate = withStyles(theme => ({ + const styles = (theme: Theme) => createStyles({ main: {}, - })); - - type classList = 'main'; + }); - interface IProps { + interface Props extends WithStyles { someProp?: string; } - class SomeComponent extends React.PureComponent> { + class SomeComponent extends React.PureComponent { render() { return
; } } - const DecoratedSomeComponent = decorate(SomeComponent); // note that I don't specify a generic type here + const DecoratedSomeComponent = withStyles(styles)(SomeComponent); ; } diff --git a/packages/material-ui/test/typescript/styling-comparison.spec.tsx b/packages/material-ui/test/typescript/styling-comparison.spec.tsx index a437516e1096b6..0e6b7f998d3f30 100644 --- a/packages/material-ui/test/typescript/styling-comparison.spec.tsx +++ b/packages/material-ui/test/typescript/styling-comparison.spec.tsx @@ -1,29 +1,29 @@ import * as React from 'react'; import Typography, { TypographyProps } from '../../src/Typography/Typography'; -import { withStyles, WithStyles } from '../../src/styles'; +import { withStyles, WithStyles, createStyles, Theme } from '../../src/styles'; -const decorate = withStyles(({ palette, spacing }) => ({ +const styles = ({ palette, spacing }: Theme) => createStyles({ root: { padding: spacing.unit, backgroundColor: palette.background.default, color: palette.primary.dark, }, -})); +}) -interface Props { +interface Props extends WithStyles { color: TypographyProps['color']; text: string; variant: TypographyProps['variant']; } -const DecoratedSFC = decorate(({ text, variant, color, classes }) => ( +const DecoratedSFC = withStyles(styles)(({ text, variant, color, classes }: Props) => ( {text} )); -const DecoratedClass = decorate( - class extends React.Component> { +const DecoratedClass = withStyles(styles)( + class extends React.Component { render() { const { text, variant, color, classes } = this.props; return ( @@ -35,8 +35,8 @@ const DecoratedClass = decorate( }, ); -const DecoratedNoProps = decorate( - class extends React.Component> { +const DecoratedNoProps = withStyles(styles)( + class extends React.Component> { render() { return Hello, World!; } @@ -59,8 +59,8 @@ interface Painting { type ArtProps = Book | Painting; -const DecoratedUnionProps = decorate( // <-- without the type argument, we'd get a compiler error! - class extends React.Component> { +const DecoratedUnionProps = withStyles(styles)( // <-- without the type argument, we'd get a compiler error! + class extends React.Component> { render() { const props = this.props; return (