-
-
Notifications
You must be signed in to change notification settings - Fork 32.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[typescript] createStyles and improved WithStyles helpers #11609
Changes from 6 commits
ed00c84
ea88ab8
cb6adb7
d033ba3
8094a7d
e51ee27
828e664
15490cd
e3d28e3
4c89da6
70c9c16
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,45 +5,129 @@ 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: | ||
The usage of `withStyles` in TypeScript can be a little tricky for a number of reasons, but | ||
|
||
```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); // Error! | ||
``` | ||
|
||
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. | ||
|
||
```js | ||
### 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 | ||
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; | ||
}; | ||
} | ||
|
||
const styles = (theme: Theme) => createStyles({ | ||
root: { /* ... */ }, | ||
paper: { /* ... */ }, | ||
button: { /* ... */ }, | ||
}); | ||
``` | ||
|
||
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'; | ||
|
||
interface Props extends WithStyles<typeof styles> { | ||
foo: number; | ||
bar: boolean; | ||
} | ||
|
||
const styles = (theme: Theme) => createStyles({ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe first There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Types can refer to things in any order, and at least in my code I tend to put all type declarations at the top of the file. But I don't feel too strongly about it. |
||
root: { /* ... */ }, | ||
paper: { /* ... */ }, | ||
button: { /* ... */ }, | ||
}); | ||
``` | ||
|
||
Functional components are straightforward: | ||
### Decorating components | ||
|
||
Applying the `withStyles(styles)` decorator as a function works as expected: | ||
|
||
```jsx | ||
const DecoratedSFC = decorate<Props>(({ text, type, color, classes }) => ( | ||
```tsx | ||
const DecoratedSFC = withStyles(styles)(({ text, type, color, classes }: Props) => ( | ||
<Typography variant={type} color={color} classes={classes}> | ||
{text} | ||
</Typography> | ||
)); | ||
``` | ||
|
||
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<Props & WithStyles<'root'>> { | ||
const DecoratedClass = withStyles(styles)( | ||
class extends React.Component<Props> { | ||
render() { | ||
const { text, type, color, classes } = this.props | ||
return ( | ||
|
@@ -56,11 +140,13 @@ const DecoratedClass = decorate( | |
); | ||
``` | ||
|
||
When your `props` are a union, Typescript needs you to explicitly tell it the type, by providing a generic `<Props>` 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 `<Props>` parameter to `decorate`: | ||
|
||
```tsx | ||
interface Book { | ||
category: "book"; | ||
author: string; | ||
|
@@ -71,10 +157,12 @@ interface Painting { | |
artist: string; | ||
} | ||
|
||
type Props = Book | Painting; | ||
type BookOrPainting = Book | Painting; | ||
|
||
const DecoratedUnionProps = decorate<Props>( // <-- without the type argument, we'd get a compiler error! | ||
class extends React.Component<Props & WithStyles<'root'>> { | ||
type Props = BookOrPainting & WithStyles<typeof styles>; | ||
|
||
const DecoratedUnionProps = withStyles(styles)<BookOrPainting>( // <-- without the type argument, we'd get a compiler error! | ||
class extends React.Component<Props> { | ||
render() { | ||
const props = this.props; | ||
return ( | ||
|
@@ -87,51 +175,14 @@ const DecoratedUnionProps = decorate<Props>( // <-- 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<keyof ReturnType<typeof styles>>; | ||
|
||
const Component: React.SFC<PropsWithStyles> = ({ | ||
classes, | ||
...props | ||
}: PropsWithStyles) => ( | ||
<div> | ||
<div className={classes.one}>One</div> | ||
<div className={classes.two}>Two</div> | ||
</div> | ||
); | ||
|
||
export default withStyles(styles)<Props>(Component); | ||
``` | ||
|
||
## Customization of `Theme` | ||
|
||
When adding custom properties to the `Theme`, you may continue to use it in a strongly typed way by exploiting | ||
[Typescript's module augmentation](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation). | ||
|
||
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 +205,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 +221,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' }}); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -75,6 +75,7 @@ export { | |
Theme, | ||
withStyles, | ||
WithStyles, | ||
createStyles, | ||
withTheme, | ||
WithTheme, | ||
} from './styles'; | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<S extends StyleRules>(styles: S): S; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
// @flow | ||
|
||
export default function createStyles(s: Object) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's funny, life is a circle, we had this function in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I didn't know that... did it serve the same purpose? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nop, we used it to perform some operations on the styles. |
||
return s; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a little nit-picking, but does If not, we could add another tests that just check for There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -37,9 +37,14 @@ export interface WithStylesOptions<ClassKey extends string = string> extends JSS | |
|
||
export type ClassNameMap<ClassKey extends string = string> = Record<ClassKey, string>; | ||
|
||
export interface WithStyles<ClassKey extends string = string> extends Partial<WithTheme> { | ||
classes: ClassNameMap<ClassKey>; | ||
} | ||
export type WithStyles<T extends string | StyleRules | StyleRulesCallback> = Partial<WithTheme> & { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is absolutely awesome and a good use case for conditional types 🤩 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Removing the default There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @geirsagberg right you are. I've opened #11808 to fix that. |
||
classes: ClassNameMap< | ||
T extends string ? T : | ||
T extends StyleRulesCallback<infer K> ? K : | ||
T extends StyleRules<infer K> ? K : | ||
never | ||
>; | ||
}; | ||
|
||
export interface StyledComponentProps<ClassKey extends string = string> { | ||
classes?: Partial<ClassNameMap<ClassKey>>; | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there something missing after the "but"? 🙂
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oops 😬