diff --git a/docs/src/pages/component-api/MobileStepper/MobileStepper.md b/docs/src/pages/component-api/MobileStepper/MobileStepper.md new file mode 100644 index 00000000000000..e7ce8c6302025c --- /dev/null +++ b/docs/src/pages/component-api/MobileStepper/MobileStepper.md @@ -0,0 +1,40 @@ +# MobileStepper + + + +## Props +| Name | Type | Default | Description | +|:-----|:-----|:--------|:------------| +| activeStep | number | 0 | Set the active step (zero based index). This will enable `Step` control helpers. | +| backButtonText | node | 'Back' | Set the text that appears for the back button. | +| classes | object | | Useful to extend the style applied to components. | +| disableBack | bool | false | Set to true to disable the back button. | +| disableNext | bool | false | Set to true to disable the next button. | +| nextButtonText | node | 'Next' | Set the text that appears for the next button. | +| onBack * | function | | Passed into the onTouchTap prop of the Back button. | +| onNext * | function | | Passed into the onTouchTap prop of the Next button. | +| position | enum: 'bottom'
 'top'
 'static'
| 'bottom' | Set the text that appears for the next button. | +| steps * | number | | The total steps. | +| type | enum: 'text'
 'dots'
 'progress'
| 'dots' | The type of mobile stepper to use. | + +Any other properties supplied will be spread to the root element. +## Classes + +You can overrides all the class names injected by Material-UI thanks to the `classes` property. +This property accepts the following keys: +- `root` +- `position-bottom` +- `positon-top` +- `position-static` +- `button` +- `dots` +- `dot` +- `dotActive` +- `progress` + +Have a look at [overriding with class names](/customization/overrides#overriding-with-class-names) +section for more detail. + +If using the `overrides` key of the theme as documented +[here](/customization/themes#customizing-all-instances-of-a-component-type), +you need to use the following style sheet name: `MuiMobileStepper`. diff --git a/docs/src/pages/component-demos/stepper/DotsMobileStepper.js b/docs/src/pages/component-demos/stepper/DotsMobileStepper.js new file mode 100644 index 00000000000000..4a0ddde52f2924 --- /dev/null +++ b/docs/src/pages/component-demos/stepper/DotsMobileStepper.js @@ -0,0 +1,54 @@ +// @flow + +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { withStyles, createStyleSheet } from 'material-ui/styles'; +import MobileStepper from 'material-ui/MobileStepper'; + +const styleSheet = createStyleSheet('DotsMobileStepper', { + root: { + maxWidth: 400, + flexGrow: 1, + }, +}); + +class DotsMobileStepper extends Component { + state = { + activeStep: 0, + }; + + handleNext = () => { + this.setState({ + activeStep: this.state.activeStep + 1, + }); + }; + + handleBack = () => { + this.setState({ + activeStep: this.state.activeStep - 1, + }); + }; + + render() { + const classes = this.props.classes; + return ( + + ); + } +} + +DotsMobileStepper.propTypes = { + classes: PropTypes.object.isRequired, +}; + +export default withStyles(styleSheet)(DotsMobileStepper); diff --git a/docs/src/pages/component-demos/stepper/ProgressMobileStepper.js b/docs/src/pages/component-demos/stepper/ProgressMobileStepper.js new file mode 100644 index 00000000000000..93f72d2628629d --- /dev/null +++ b/docs/src/pages/component-demos/stepper/ProgressMobileStepper.js @@ -0,0 +1,54 @@ +// @flow + +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { withStyles, createStyleSheet } from 'material-ui/styles'; +import MobileStepper from 'material-ui/MobileStepper'; + +const styleSheet = createStyleSheet('ProgressMobileStepper', { + root: { + maxWidth: 400, + flexGrow: 1, + }, +}); + +class ProgressMobileStepper extends Component { + state = { + activeStep: 0, + }; + + handleNext = () => { + this.setState({ + activeStep: this.state.activeStep + 1, + }); + }; + + handleBack = () => { + this.setState({ + activeStep: this.state.activeStep - 1, + }); + }; + + render() { + const classes = this.props.classes; + return ( + + ); + } +} + +ProgressMobileStepper.propTypes = { + classes: PropTypes.object.isRequired, +}; + +export default withStyles(styleSheet)(ProgressMobileStepper); diff --git a/docs/src/pages/component-demos/stepper/TextMobileStepper.js b/docs/src/pages/component-demos/stepper/TextMobileStepper.js new file mode 100644 index 00000000000000..6b1a77ff98dac1 --- /dev/null +++ b/docs/src/pages/component-demos/stepper/TextMobileStepper.js @@ -0,0 +1,71 @@ +// @flow + +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { withStyles, createStyleSheet } from 'material-ui/styles'; +import MobileStepper from 'material-ui/MobileStepper'; +import Paper from 'material-ui/Paper'; +import Typography from 'material-ui/Typography'; + +const styleSheet = createStyleSheet('TextMobileStepper', theme => ({ + root: { + maxWidth: 400, + flexGrow: 1, + }, + header: { + display: 'flex', + alignItems: 'center', + height: 50, + paddingLeft: theme.spacing.unit * 5, + marginBottom: 20, + background: theme.palette.background.default, + }, +})); + +class TextMobileStepper extends Component { + state = { + activeStep: 0, + }; + + handleNext = () => { + this.setState({ + activeStep: this.state.activeStep + 1, + }); + }; + + handleBack = () => { + this.setState({ + activeStep: this.state.activeStep - 1, + }); + }; + + render() { + const classes = this.props.classes; + return ( +
+ + + Step {this.state.activeStep + 1} of 6 + + + +
+ ); + } +} + +TextMobileStepper.propTypes = { + classes: PropTypes.object.isRequired, +}; + +export default withStyles(styleSheet)(TextMobileStepper); diff --git a/docs/src/pages/component-demos/stepper/stepper.md b/docs/src/pages/component-demos/stepper/stepper.md new file mode 100644 index 00000000000000..3438946c1dd5e9 --- /dev/null +++ b/docs/src/pages/component-demos/stepper/stepper.md @@ -0,0 +1,31 @@ +--- +components: MobileStepper +--- + +# Stepper + +[Steppers](https://material.io/guidelines/components/steppers.html) convey progress through numbered steps. + +## Mobile Stepper + +The [mobile steps](https://material.io/guidelines/components/steppers.html#steppers-types-of-steps) implements a compact stepper suitable for a mobile device. + +### Mobile Stepper - Text + +This is essentially a back/next button positioned correctly. +You must implement the textual description yourself however an example is provided below for reference. + +{{demo='pages/component-demos/stepper/TextMobileStepper.js'}} + +### Mobile Stepper - Dots + +Use dots when the number of steps isn’t large. + +{{demo='pages/component-demos/stepper/DotsMobileStepper.js'}} + +### Mobile Stepper - Progress + +Use a progress bar when there are many steps, or if there are steps that need to be inserted during the process (based on responses to earlier steps). + +{{demo='pages/component-demos/stepper/ProgressMobileStepper.js'}} + diff --git a/docs/src/pages/getting-started/supported-components.md b/docs/src/pages/getting-started/supported-components.md index dc239579be2e2a..a1e152196530b3 100644 --- a/docs/src/pages/getting-started/supported-components.md +++ b/docs/src/pages/getting-started/supported-components.md @@ -96,6 +96,7 @@ to discuss the approach before submitting a PR. - [Steppers](https://www.google.com/design/spec/components/steppers.html) - [Horizontal](https://www.google.com/design/spec/components/steppers.html#steppers-types-of-steppers) - [Vertical](https://www.google.com/design/spec/components/steppers.html#steppers-types-of-steppers) + - **[Mobile steps](https://material.io/guidelines/components/steppers.html#steppers-types-of-steps) ✓** - **[Tabs](https://www.google.com/design/spec/components/tabs.html) ✓** - Usage - **[Mobile (Full width)](https://www.google.com/design/spec/components/tabs.html#tabs-usage) ✓** diff --git a/src/MobileStepper/MobileStepper.js b/src/MobileStepper/MobileStepper.js new file mode 100644 index 00000000000000..b1633c084be119 --- /dev/null +++ b/src/MobileStepper/MobileStepper.js @@ -0,0 +1,167 @@ +// @flow weak + +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { createStyleSheet } from 'jss-theme-reactor'; +import withStyles from '../styles/withStyles'; +import Paper from '../Paper'; +import Button from '../Button'; +import KeyboardArrowLeft from '../svg-icons/keyboard-arrow-left'; +import KeyboardArrowRight from '../svg-icons/keyboard-arrow-right'; +import { LinearProgress } from '../Progress'; + +export const styleSheet = createStyleSheet('MuiMobileStepper', theme => ({ + root: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + background: theme.palette.background.default, + height: 50, + }, + 'position-bottom': { + position: 'fixed', + bottom: 0, + left: 0, + right: 0, + zIndex: theme.zIndex.mobileStepper, + }, + 'position-top': { + position: 'fixed', + top: 0, + left: 0, + right: 0, + zIndex: theme.zIndex.mobileStepper, + }, + 'position-static': {}, + button: {}, + dots: { + display: 'flex', + flexDirection: 'row', + }, + dot: { + backgroundColor: theme.palette.action.disabled, + borderRadius: '50%', + width: theme.spacing.unit, + height: theme.spacing.unit, + margin: '0 2px', + }, + dotActive: { + backgroundColor: theme.palette.primary[500], + }, + progress: { + width: '50%', + }, +})); + +function MobileStepper(props) { + const { + activeStep, + backButtonText, + classes, + className: classNameProp, + disableBack, + disableNext, + position, + type, + nextButtonText, + onBack, + onNext, + steps, + ...other + } = props; + + const className = classNames(classes.root, classes[`position-${position}`], classNameProp); + + return ( + + + {type === 'dots' && +
+ {Array.from(Array(steps)).map((_, step) => { + const dotClassName = classNames( + { + [classes.dotActive]: step === activeStep, + }, + classes.dot, + ); + // eslint-disable-next-line react/no-array-index-key + return
; + })} +
} + {type === 'progress' && +
+ +
} + + + ); +} + +MobileStepper.propTypes = { + /** + * Set the active step (zero based index). This will enable `Step` control helpers. + */ + activeStep: PropTypes.number, + /** + * Set the text that appears for the back button. + */ + backButtonText: PropTypes.node, + /** + * Useful to extend the style applied to components. + */ + classes: PropTypes.object.isRequired, + /** + * @ignore + */ + className: PropTypes.string, + /** + * Set to true to disable the back button. + */ + disableBack: PropTypes.bool, + /** + * Set to true to disable the next button. + */ + disableNext: PropTypes.bool, + /** + * Set the text that appears for the next button. + */ + nextButtonText: PropTypes.node, + /** + * Passed into the onTouchTap prop of the Back button. + */ + onBack: PropTypes.func.isRequired, + /** + * Passed into the onTouchTap prop of the Next button. + */ + onNext: PropTypes.func.isRequired, + /** + * Set the text that appears for the next button. + */ + position: PropTypes.oneOf(['bottom', 'top', 'static']), + /** + * The total steps. + */ + steps: PropTypes.number.isRequired, + /** + * The type of mobile stepper to use. + */ + type: PropTypes.oneOf(['text', 'dots', 'progress']), +}; + +MobileStepper.defaultProps = { + activeStep: 0, + backButtonText: 'Back', + disableBack: false, + disableNext: false, + nextButtonText: 'Next', + position: 'bottom', + type: 'dots', +}; + +export default withStyles(styleSheet)(MobileStepper); diff --git a/src/MobileStepper/MobileStepper.spec.js b/src/MobileStepper/MobileStepper.spec.js new file mode 100644 index 00000000000000..0dadff72c6bf60 --- /dev/null +++ b/src/MobileStepper/MobileStepper.spec.js @@ -0,0 +1,174 @@ +// @flow + +import React from 'react'; +import { assert } from 'chai'; +import { createShallow } from '../test-utils'; +import MobileStepper, { styleSheet } from './MobileStepper'; +import Button from '../Button/Button'; +import KeyboardArrowLeft from '../svg-icons/keyboard-arrow-left'; +import KeyboardArrowRight from '../svg-icons/keyboard-arrow-right'; +import { LinearProgress } from '../Progress'; + +describe('', () => { + let shallow; + let classes; + const defaultProps = { + steps: 2, + onBack: () => {}, + onNext: () => {}, + }; + + before(() => { + shallow = createShallow({ dive: true }); + classes = shallow.context.styleManager.render(styleSheet); + }); + + it('should render a Paper component', () => { + const wrapper = shallow(); + assert.strictEqual(wrapper.name(), 'withStyles(Paper)'); + assert.strictEqual(wrapper.props().elevation, 0, 'should have no elevation'); + }); + + it('should render with the root class', () => { + const wrapper = shallow(); + assert.strictEqual(wrapper.hasClass(classes.root), true, 'should have the root class'); + }); + + it('should render the custom className and the root class', () => { + const wrapper = shallow(); + assert.strictEqual(wrapper.is('.test-class-name'), true, 'should pass the test className'); + assert.strictEqual(wrapper.hasClass(classes.root), true, 'should have the mobileStepper class'); + }); + + it('should render with the bottom class if position prop is set to bottom', () => { + const wrapper = shallow(); + assert.strictEqual(wrapper.hasClass(classes['position-bottom']), true); + }); + + it('should render with the top class if position prop is set to top', () => { + const wrapper = shallow(); + assert.strictEqual(wrapper.hasClass(classes['position-top']), true); + }); + + it('should render two buttons', () => { + const wrapper = shallow(); + assert.lengthOf(wrapper.find(Button), 2, 'should render two buttons'); + }); + + it('should render the back button', () => { + const wrapper = shallow(); + const backButton = wrapper.childAt(0); + assert.strictEqual(backButton.childAt(1).text(), 'Back', 'should set the back button text'); + assert.lengthOf( + backButton.find(KeyboardArrowLeft), + 1, + 'should render a single component', + ); + }); + + it('should render next button', () => { + const wrapper = shallow(); + const nextButton = wrapper.childAt(2); + assert.strictEqual(nextButton.childAt(0).text(), 'Next', 'should set the next button text'); + assert.lengthOf( + nextButton.find(KeyboardArrowRight), + 1, + 'should render a single component', + ); + }); + + it('should set the backButtonText', () => { + const wrapper = shallow(); + assert.strictEqual( + wrapper.childAt(0).childAt(1).text(), + 'Past', + 'should set the back button text', + ); + }); + + it('should set the nextButtonText', () => { + const wrapper = shallow(); + assert.strictEqual( + wrapper.childAt(2).childAt(0).text(), + 'Future', + 'should set the back button text', + ); + }); + + it('should disable the back button if prop disableBack is passed', () => { + const wrapper = shallow(); + const backButton = wrapper.childAt(0); + assert.strictEqual(backButton.props().disabled, true, 'should disable the back button'); + }); + + it('should disable the next button if prop disableNext is passed', () => { + const wrapper = shallow(); + const nextButton = wrapper.childAt(2); + assert.strictEqual(nextButton.props().disabled, true, 'should disable the next button'); + }); + + it('should render just two buttons when supplied with type text', () => { + const wrapper = shallow(); + assert.lengthOf(wrapper.children(), 2, 'should render exactly two children'); + }); + + it('should render dots when supplied with type dots', () => { + const wrapper = shallow(); + assert.lengthOf(wrapper.children(), 3, 'should render exactly three children'); + assert.strictEqual( + wrapper.childAt(1).hasClass(classes.dots), + true, + 'should have a single dots class', + ); + }); + + it('should render a dot for each step when using dots type', () => { + const wrapper = shallow(); + assert.lengthOf(wrapper.find(`.${classes.dot}`), 2, 'should render exactly two dots'); + }); + + it('should render the first dot as active if activeStep is not set', () => { + const wrapper = shallow(); + assert.strictEqual( + wrapper.childAt(1).childAt(0).hasClass(classes.dotActive), + true, + 'should render the first dot active', + ); + }); + + it('should honour the activeStep prop', () => { + const wrapper = shallow(); + assert.strictEqual( + wrapper.childAt(1).childAt(1).hasClass(classes.dotActive), + true, + 'should render the second dot active', + ); + }); + + it('should render a when supplied with type progress', () => { + const wrapper = shallow(); + assert.lengthOf(wrapper.find(LinearProgress), 1, 'should render a '); + }); + + it('should calculate the value correctly', () => { + const props = { + onBack: defaultProps.onBack, + onNext: defaultProps.onNext, + }; + let wrapper = shallow(); + let linearProgressProps = wrapper.find(LinearProgress).props(); + assert.strictEqual(linearProgressProps.value, 0, 'should set value to 0'); + + wrapper = shallow(); + linearProgressProps = wrapper.find(LinearProgress).props(); + assert.strictEqual(linearProgressProps.value, 50, 'should set value to 50'); + + wrapper = shallow(); + linearProgressProps = wrapper.find(LinearProgress).props(); + assert.strictEqual( + linearProgressProps.value, + 100, + 'should set value to 100', + ); + }); +}); diff --git a/src/MobileStepper/index.js b/src/MobileStepper/index.js new file mode 100644 index 00000000000000..dbd564de92a83f --- /dev/null +++ b/src/MobileStepper/index.js @@ -0,0 +1,3 @@ +// @flow + +export { default } from './MobileStepper'; diff --git a/src/styles/muiThemeProviderFactory.js b/src/styles/muiThemeProviderFactory.js index da9980e8bea1a0..c0ae1314af11bb 100644 --- a/src/styles/muiThemeProviderFactory.js +++ b/src/styles/muiThemeProviderFactory.js @@ -96,6 +96,8 @@ export const MUI_SHEET_ORDER = [ 'MuiToolbar', 'MuiBadge', + + 'MuiMobileStepper', ]; export default function muiThemeProviderFactory(defaultTheme) { diff --git a/src/styles/zIndex.js b/src/styles/zIndex.js index f522efbb7ad5b2..fcc42e5b8da657 100644 --- a/src/styles/zIndex.js +++ b/src/styles/zIndex.js @@ -2,6 +2,7 @@ // Needed as the zIndex works with absolute values. export default { + mobileStepper: 900, menu: 1000, appBar: 1100, drawerOverlay: 1200,