From d9ef8e8a9fe87a1a6443b2d0c4e3bd49052ec89e Mon Sep 17 00:00:00 2001 From: antialiasis Date: Thu, 7 Jul 2016 20:14:44 +0000 Subject: [PATCH] [BottomSheet] Add BottomSheet component and refactor Drawer to also use internal SlidingSheet component. --- docs/src/app/AppRoutes.js | 2 + docs/src/app/components/AppNavDrawer.js | 5 + .../BottomSheet/ExampleCloseable.js | 35 ++ .../components/BottomSheet/ExampleInset.js | 35 ++ .../components/BottomSheet/ExampleModal.js | 39 ++ .../components/BottomSheet/ExampleSimple.js | 33 ++ .../pages/components/BottomSheet/Page.js | 51 ++ .../pages/components/BottomSheet/README.md | 6 + .../components/Drawer/ExampleOpenSecondary.js | 12 +- .../pages/components/Drawer/ExampleSimple.js | 13 +- .../components/Drawer/ExampleUndocked.js | 14 +- .../pages/components/Drawer/Page.js | 36 +- src/BottomSheet/BottomSheet.js | 161 +++++++ src/BottomSheet/index.js | 1 + src/Drawer/Drawer.js | 271 ++--------- src/index.js | 1 + src/internal/SlidingSheet.js | 439 ++++++++++++++++++ src/styles/getMuiTheme.js | 3 + src/styles/zIndex.js | 2 + 19 files changed, 879 insertions(+), 280 deletions(-) create mode 100644 docs/src/app/components/pages/components/BottomSheet/ExampleCloseable.js create mode 100644 docs/src/app/components/pages/components/BottomSheet/ExampleInset.js create mode 100644 docs/src/app/components/pages/components/BottomSheet/ExampleModal.js create mode 100644 docs/src/app/components/pages/components/BottomSheet/ExampleSimple.js create mode 100644 docs/src/app/components/pages/components/BottomSheet/Page.js create mode 100644 docs/src/app/components/pages/components/BottomSheet/README.md create mode 100644 src/BottomSheet/BottomSheet.js create mode 100644 src/BottomSheet/index.js create mode 100644 src/internal/SlidingSheet.js diff --git a/docs/src/app/AppRoutes.js b/docs/src/app/AppRoutes.js index 19ea502e1433d2..97093371348ba1 100644 --- a/docs/src/app/AppRoutes.js +++ b/docs/src/app/AppRoutes.js @@ -23,6 +23,7 @@ import AppBarPage from './components/pages/components/AppBar/Page'; import AutoCompletePage from './components/pages/components/AutoComplete/Page'; import AvatarPage from './components/pages/components/Avatar/Page'; import BadgePage from './components/pages/components/Badge/Page'; +import BottomSheetPage from './components/pages/components/BottomSheet/Page'; import CardPage from './components/pages/components/Card/Page'; import ChipPage from './components/pages/components/Chip/Page'; import CircularProgressPage from './components/pages/components/CircularProgress/Page'; @@ -98,6 +99,7 @@ const AppRoutes = ( + diff --git a/docs/src/app/components/AppNavDrawer.js b/docs/src/app/components/AppNavDrawer.js index 2a17e0d8dc907f..ad4d217fc7658f 100644 --- a/docs/src/app/components/AppNavDrawer.js +++ b/docs/src/app/components/AppNavDrawer.js @@ -186,6 +186,11 @@ class AppNavDrawer extends Component { value="/components/badge" href="/#/components/badge" />, + , this.setState({open: !this.state.open}); + + handleClose = () => this.setState({open: false}); + + render() { + return ( +
+ + + Menu item 1 + Menu item 2 + +
+ ); + } +} diff --git a/docs/src/app/components/pages/components/BottomSheet/ExampleInset.js b/docs/src/app/components/pages/components/BottomSheet/ExampleInset.js new file mode 100644 index 00000000000000..8a7fe78103c658 --- /dev/null +++ b/docs/src/app/components/pages/components/BottomSheet/ExampleInset.js @@ -0,0 +1,35 @@ +import React, {Component} from 'react'; +import BottomSheet from 'material-ui/BottomSheet'; +import MenuItem from 'material-ui/MenuItem'; +import RaisedButton from 'material-ui/RaisedButton'; + +/** + * An inset `BottomSheet` with a custom width. + */ + +export default class BottomSheetExampleInset extends Component { + + constructor() { + super(); + this.state = {open: false}; + } + + handleToggle = () => this.setState({open: !this.state.open}); + + handleClose = () => this.setState({open: false}); + + render() { + return ( +
+ + + Menu item 1 + Menu item 2 + +
+ ); + } +} diff --git a/docs/src/app/components/pages/components/BottomSheet/ExampleModal.js b/docs/src/app/components/pages/components/BottomSheet/ExampleModal.js new file mode 100644 index 00000000000000..368f3a9d1d37c2 --- /dev/null +++ b/docs/src/app/components/pages/components/BottomSheet/ExampleModal.js @@ -0,0 +1,39 @@ +import React, {Component} from 'react'; +import BottomSheet from 'material-ui/BottomSheet'; +import MenuItem from 'material-ui/MenuItem'; +import RaisedButton from 'material-ui/RaisedButton'; + +/** + * A modal `BottomSheet`. When opened, the page is covered with an overlay. Touching + * the overlay or swiping down will dismiss the `BottomSheet`. + */ + +export default class BottomSheetExampleModal extends Component { + + constructor() { + super(); + this.state = {open: false}; + } + + handleToggle = () => this.setState({open: !this.state.open}); + + handleClose = () => this.setState({open: false}); + + render() { + return ( +
+ + + Menu item 1 + Menu item 2 + Menu item 3 + Menu item 4 + Menu item 5 + +
+ ); + } +} diff --git a/docs/src/app/components/pages/components/BottomSheet/ExampleSimple.js b/docs/src/app/components/pages/components/BottomSheet/ExampleSimple.js new file mode 100644 index 00000000000000..88b244c0e6609e --- /dev/null +++ b/docs/src/app/components/pages/components/BottomSheet/ExampleSimple.js @@ -0,0 +1,33 @@ +import React, {Component} from 'react'; +import BottomSheet from 'material-ui/BottomSheet'; +import MenuItem from 'material-ui/MenuItem'; +import RaisedButton from 'material-ui/RaisedButton'; + +/** + * A simple persistent `BottomSheet`, controlled through the `open` prop. + */ + +export default class BottomSheetExampleSimple extends Component { + + constructor() { + super(); + this.state = {open: false}; + } + + handleToggle = () => this.setState({open: !this.state.open}); + + render() { + return ( +
+ + + Menu item 1 + Menu item 2 + +
+ ); + } +} diff --git a/docs/src/app/components/pages/components/BottomSheet/Page.js b/docs/src/app/components/pages/components/BottomSheet/Page.js new file mode 100644 index 00000000000000..65563f0a8ea5de --- /dev/null +++ b/docs/src/app/components/pages/components/BottomSheet/Page.js @@ -0,0 +1,51 @@ +import React from 'react'; +import Title from 'react-title-component'; + +import CodeExample from '../../../CodeExample'; +import PropTypeDescription from '../../../PropTypeDescription'; +import MarkdownElement from '../../../MarkdownElement'; + +import bottomSheetReadmeText from './README'; +import BottomSheetExampleSimple from './ExampleSimple'; +import bottomSheetExampleSimpleCode from '!raw!./ExampleSimple'; +import BottomSheetExampleCloseable from './ExampleCloseable'; +import bottomSheetExampleCloseableCode from '!raw!./ExampleCloseable'; +import BottomSheetExampleInset from './ExampleInset'; +import bottomSheetExampleInsetCode from '!raw!./ExampleInset'; +import BottomSheetExampleModal from './ExampleModal'; +import bottomSheetExampleModalCode from '!raw!./ExampleModal'; +import bottomSheetCode from '!raw!material-ui/BottomSheet/BottomSheet'; + +const BottomSheetPage = () => ( +
+ `Bottom Sheet - ${previousTitle}`} /> + <MarkdownElement text={bottomSheetReadmeText} /> + <CodeExample + title="Simple example" + code={bottomSheetExampleSimpleCode} + > + <BottomSheetExampleSimple /> + </CodeExample> + <CodeExample + title="Closeable example" + code={bottomSheetExampleCloseableCode} + > + <BottomSheetExampleCloseable /> + </CodeExample> + <CodeExample + title="Inset example" + code={bottomSheetExampleInsetCode} + > + <BottomSheetExampleInset /> + </CodeExample> + <CodeExample + title="Modal example" + code={bottomSheetExampleModalCode} + > + <BottomSheetExampleModal /> + </CodeExample> + <PropTypeDescription code={bottomSheetCode} /> + </div> +); + +export default BottomSheetPage; diff --git a/docs/src/app/components/pages/components/BottomSheet/README.md b/docs/src/app/components/pages/components/BottomSheet/README.md new file mode 100644 index 00000000000000..dc9c6d9a36181c --- /dev/null +++ b/docs/src/app/components/pages/components/BottomSheet/README.md @@ -0,0 +1,6 @@ +## BottomSheet + +The [BottomSheet](https://www.google.com/design/spec/components/bottom-sheets.html) slides up from the bottom. +It is most commonly used on mobile. + +### Examples diff --git a/docs/src/app/components/pages/components/Drawer/ExampleOpenSecondary.js b/docs/src/app/components/pages/components/Drawer/ExampleOpenSecondary.js index ab7a85b995a7b8..21089a0658b390 100644 --- a/docs/src/app/components/pages/components/Drawer/ExampleOpenSecondary.js +++ b/docs/src/app/components/pages/components/Drawer/ExampleOpenSecondary.js @@ -1,12 +1,16 @@ -import React from 'react'; +import React, {Component} from 'react'; import Drawer from 'material-ui/Drawer'; import AppBar from 'material-ui/AppBar'; import RaisedButton from 'material-ui/RaisedButton'; -export default class DrawerOpenRightExample extends React.Component { +/** + * The `openSecondary` prop allows the Drawer to open on the opposite side. + */ - constructor(props) { - super(props); +export default class DrawerExampleOpenSecondary extends Component { + + constructor() { + super(); this.state = {open: false}; } diff --git a/docs/src/app/components/pages/components/Drawer/ExampleSimple.js b/docs/src/app/components/pages/components/Drawer/ExampleSimple.js index 52118c66137e31..a2333e5f45c25a 100644 --- a/docs/src/app/components/pages/components/Drawer/ExampleSimple.js +++ b/docs/src/app/components/pages/components/Drawer/ExampleSimple.js @@ -1,12 +1,17 @@ -import React from 'react'; +import React, {Component} from 'react'; import Drawer from 'material-ui/Drawer'; import MenuItem from 'material-ui/MenuItem'; import RaisedButton from 'material-ui/RaisedButton'; -export default class DrawerSimpleExample extends React.Component { +/** + * A simple controlled `Drawer`. The Drawer is `docked` by default, remaining open + * unless closed through the `open` prop. + */ - constructor(props) { - super(props); +export default class DrawerExampleSimple extends Component { + + constructor() { + super(); this.state = {open: false}; } diff --git a/docs/src/app/components/pages/components/Drawer/ExampleUndocked.js b/docs/src/app/components/pages/components/Drawer/ExampleUndocked.js index 8362906f2e58bd..31448e5b0db496 100644 --- a/docs/src/app/components/pages/components/Drawer/ExampleUndocked.js +++ b/docs/src/app/components/pages/components/Drawer/ExampleUndocked.js @@ -1,12 +1,18 @@ -import React from 'react'; +import React, {Component} from 'react'; import Drawer from 'material-ui/Drawer'; import MenuItem from 'material-ui/MenuItem'; import RaisedButton from 'material-ui/RaisedButton'; -export default class DrawerUndockedExample extends React.Component { +/** + * An undocked controlled `Drawer` with custom width. The Drawer can be cancelled by + * clicking the overlay or pressing the Esc key. It closes when an item is selected, + * handled by controlling the `open` prop. + */ - constructor(props) { - super(props); +export default class DrawerExampleUndocked extends Component { + + constructor() { + super(); this.state = {open: false}; } diff --git a/docs/src/app/components/pages/components/Drawer/Page.js b/docs/src/app/components/pages/components/Drawer/Page.js index 594ddecfdf06fc..8be9e02bb3b6b1 100644 --- a/docs/src/app/components/pages/components/Drawer/Page.js +++ b/docs/src/app/components/pages/components/Drawer/Page.js @@ -6,47 +6,35 @@ import PropTypeDescription from '../../../PropTypeDescription'; import MarkdownElement from '../../../MarkdownElement'; import drawerReadmeText from './README'; -import DrawerSimpleExample from './ExampleSimple'; -import drawerSimpleExampleCode from '!raw!./ExampleSimple'; -import DrawerUndockedExample from './ExampleUndocked'; -import drawerUndockedExampleCode from '!raw!./ExampleUndocked'; -import DrawerOpenSecondaryExample from './ExampleOpenSecondary'; -import drawerOpenSecondaryExampleCode from '!raw!./ExampleOpenSecondary'; +import DrawerExampleSimple from './ExampleSimple'; +import drawerExampleSimpleCode from '!raw!./ExampleSimple'; +import DrawerExampleUndocked from './ExampleUndocked'; +import drawerExampleUndockedCode from '!raw!./ExampleUndocked'; +import DrawerExampleOpenSecondary from './ExampleOpenSecondary'; +import drawerExampleOpenSecondaryCode from '!raw!./ExampleOpenSecondary'; import drawerCode from '!raw!material-ui/Drawer/Drawer'; -const descriptions = { - simple: 'A simple controlled `Drawer`. The Drawer is `docked` by default, ' + - 'remaining open unless closed through the `open` prop.', - undocked: 'An undocked controlled `Drawer` with custom width. ' + - 'The Drawer can be cancelled by clicking the overlay or pressing the Esc key. ' + - 'It closes when an item is selected, handled by controlling the `open` prop.', - right: 'The `openSecondary` prop allows the Drawer to open on the opposite side.', -}; - const DrawerPage = () => ( <div> <Title render={(previousTitle) => `Drawer - ${previousTitle}`} /> <MarkdownElement text={drawerReadmeText} /> <CodeExample title="Docked example" - description={descriptions.simple} - code={drawerSimpleExampleCode} + code={drawerExampleSimpleCode} > - <DrawerSimpleExample /> + <DrawerExampleSimple /> </CodeExample> <CodeExample title="Undocked example" - description={descriptions.undocked} - code={drawerUndockedExampleCode} + code={drawerExampleUndockedCode} > - <DrawerUndockedExample /> + <DrawerExampleUndocked /> </CodeExample> <CodeExample title="Open secondary example" - description={descriptions.right} - code={drawerOpenSecondaryExampleCode} + code={drawerExampleOpenSecondaryCode} > - <DrawerOpenSecondaryExample /> + <DrawerExampleOpenSecondary /> </CodeExample> <PropTypeDescription code={drawerCode} /> </div> diff --git a/src/BottomSheet/BottomSheet.js b/src/BottomSheet/BottomSheet.js new file mode 100644 index 00000000000000..7c6c6a0df6055b --- /dev/null +++ b/src/BottomSheet/BottomSheet.js @@ -0,0 +1,161 @@ +import React, {Component, PropTypes} from 'react'; +import {findDOMNode} from 'react-dom'; +import EventListener from 'react-event-listener'; +import SlidingSheet from '../internal/SlidingSheet'; + +class BottomSheet extends Component { + static propTypes = { + /** + * The contents of the `BottomSheet`. + */ + children: PropTypes.node, + /** + * The CSS class name of the root element. + */ + className: PropTypes.string, + /** + * The CSS class name of the container element. + */ + containerClassName: PropTypes.string, + /** + * Override the inline-styles of the container element. + */ + containerStyle: PropTypes.object, + /** + * If true, the `BottomSheet` will be modal. + */ + modal: PropTypes.bool, + /** + * Callback function fired when the `BottomSheet` is requested to be closed. If + * not provided, the `BottomSheet` will persist unless closed through the `open` + * prop. + * + * @param {string} reason The reason for the close request. Possible values are + * 'clickaway' (on overlay clicks), 'escape' (on escape key press), and 'swipe' + * (when swiping down). + */ + onRequestClose: PropTypes.func, + /** + * If true, the `BottomSheet` is opened. + */ + open: PropTypes.bool.isRequired, + /** + * The CSS class name to add to the `Overlay` component that is rendered behind the `BottomSheet`. + */ + overlayClassName: PropTypes.string, + /** + * Override the inline-styles of the `Overlay` component that is rendered behind the `BottomSheet`. + */ + overlayStyle: PropTypes.object, + /** + * The minimum interval between resize events. Defaults to 166. + */ + resizeInterval: PropTypes.number, + /** + * Override the inline-styles of the root element. + */ + style: PropTypes.object, + /** + * The width of the `BottomSheet` in pixels, if it should be inset. If not set, the sheet will be + * full-width. + */ + width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + }; + + static defaultProps = { + modal: false, + open: false, + resizeInterval: 166, + width: '100%', + }; + + static contextTypes = { + muiTheme: PropTypes.object.isRequired, + }; + + componentWillMount() { + this.setState({ + height: null, + }); + } + + componentDidMount() { + this.updateHeight(); + } + + componentWillReceiveProps() { + this.updateHeight(); + } + + componentWillUnmount() { + clearTimeout(this.deferTimer); + } + + handleResize = () => { + clearTimeout(this.deferTimer); + this.deferTimer = setTimeout(() => { + this.updateHeight(); + }, this.props.resizeInterval); + }; + + close(reason) { + if (this.props.onRequestClose) this.props.onRequestClose(reason); + } + + updateHeight = () => { + this.setState({height: findDOMNode(this.sheet.refs.clickAwayableElement).offsetHeight}); + }; + + handleRequestChange = (open, reason) => { + // Bottom sheets can't request open + this.updateHeight(); + if (!open) this.close(reason); + }; + + render() { + const { + children, + className, + containerClassName, + containerStyle, + modal, + open, + overlayClassName, + overlayStyle, + style, + width, + ...other, + } = this.props; + + const fullContainerStyle = Object.assign({ + backgroundColor: this.context.muiTheme.bottomSheet.color, + width: width, margin: '0 auto', + }, containerStyle); + + return ( + <EventListener target="window" onResize={this.handleResize}> + <SlidingSheet + ref={(sheet) => this.sheet = sheet} + className={className} + closeable={!!this.props.onRequestClose} + containerClassName={containerClassName} + containerStyle={fullContainerStyle} + direction="bottom" + modal={modal} + onRequestChange={this.handleRequestChange} + open={open} + overlayClassName={overlayClassName} + overlayStyle={overlayStyle} + primaryDimension={this.state.height} + style={style} + zDepth={modal ? 2 : 1} + {...other} + > + {children} + </SlidingSheet> + </EventListener> + ); + } +} + +export default BottomSheet; diff --git a/src/BottomSheet/index.js b/src/BottomSheet/index.js new file mode 100644 index 00000000000000..d3298aa921d335 --- /dev/null +++ b/src/BottomSheet/index.js @@ -0,0 +1 @@ +export default from './BottomSheet'; diff --git a/src/Drawer/Drawer.js b/src/Drawer/Drawer.js index ca2d5ff14abaa5..c9ee81600e6e37 100644 --- a/src/Drawer/Drawer.js +++ b/src/Drawer/Drawer.js @@ -1,19 +1,11 @@ import React, {Component, PropTypes} from 'react'; -import ReactDOM from 'react-dom'; -import EventListener from 'react-event-listener'; -import keycode from 'keycode'; -import autoPrefix from '../utils/autoPrefix'; -import transitions from '../styles/transitions'; -import Overlay from '../internal/Overlay'; -import Paper from '../Paper'; +import SlidingSheet from '../internal/SlidingSheet'; import propTypes from '../utils/propTypes'; -let openNavEventHandler = null; - class Drawer extends Component { static propTypes = { /** - * The contents of the `Drawer` + * The contents of the `Drawer`. */ children: PropTypes.node, /** @@ -82,7 +74,6 @@ class Drawer extends Component { * The zDepth of the `Drawer`. */ zDepth: propTypes.zDepth, - }; static defaultProps = { @@ -100,21 +91,11 @@ class Drawer extends Component { }; componentWillMount() { - this.maybeSwiping = false; - this.touchStartX = null; - this.touchStartY = null; - this.swipeStartX = null; - this.setState({ open: (this.props.open !== null ) ? this.props.open : this.props.docked, - swiping: null, }); } - componentDidMount() { - this.enableSwipeHandling(); - } - componentWillReceiveProps(nextProps) { // If controlled then the open prop takes precedence. if (nextProps.open !== null) { @@ -129,51 +110,6 @@ class Drawer extends Component { } } - componentDidUpdate() { - this.enableSwipeHandling(); - } - - componentWillUnmount() { - this.disableSwipeHandling(); - } - - getStyles() { - const muiTheme = this.context.muiTheme; - const theme = muiTheme.drawer; - - const x = this.getTranslateMultiplier() * (this.state.open ? 0 : this.getMaxTranslateX()); - - const styles = { - root: { - height: '100%', - width: this.props.width || theme.width, - position: 'fixed', - zIndex: muiTheme.zIndex.drawer, - left: 0, - top: 0, - transform: `translate3d(${x}px, 0, 0)`, - transition: !this.state.swiping && transitions.easeOut(null, 'transform', null), - backgroundColor: theme.color, - overflow: 'auto', - WebkitOverflowScrolling: 'touch', // iOS momentum scrolling - }, - overlay: { - zIndex: muiTheme.zIndex.drawerOverlay, - pointerEvents: this.state.open ? 'auto' : 'none', // Bypass mouse events when left nav is closing. - }, - rootWhenOpenRight: { - left: 'auto', - right: 0, - }, - }; - - return styles; - } - - shouldShow() { - return this.state.open || !!this.state.swiping; // component is swiping - } - close(reason) { if (this.props.open === null) this.setState({open: false}); if (this.props.onRequestChange) this.props.onRequestChange(false, reason); @@ -186,156 +122,12 @@ class Drawer extends Component { return this; } - handleTouchTapOverlay = (event) => { - event.preventDefault(); - this.close('clickaway'); - }; - - handleKeyUp = (event) => { - if (this.state.open && !this.props.docked && keycode(event) === 'esc') { - this.close('escape'); - } - }; - - getMaxTranslateX() { - const width = this.props.width || this.context.muiTheme.drawer.width; - return width + 10; - } - - getTranslateMultiplier() { - return this.props.openSecondary ? 1 : -1; - } - - enableSwipeHandling() { - if (!this.props.docked) { - document.body.addEventListener('touchstart', this.onBodyTouchStart); - if (!openNavEventHandler) { - openNavEventHandler = this.onBodyTouchStart; - } - } else { - this.disableSwipeHandling(); - } - } - - disableSwipeHandling() { - document.body.removeEventListener('touchstart', this.onBodyTouchStart); - if (openNavEventHandler === this.onBodyTouchStart) { - openNavEventHandler = null; - } - } - - onBodyTouchStart = (event) => { - const swipeAreaWidth = this.props.swipeAreaWidth; - - const touchStartX = event.touches[0].pageX; - const touchStartY = event.touches[0].pageY; - - // Open only if swiping from far left (or right) while closed - if (swipeAreaWidth !== null && !this.state.open) { - if (this.props.openSecondary) { - // If openSecondary is true calculate from the far right - if (touchStartX < document.body.offsetWidth - swipeAreaWidth) return; - } else { - // If openSecondary is false calculate from the far left - if (touchStartX > swipeAreaWidth) return; - } - } - - if (!this.state.open && - (openNavEventHandler !== this.onBodyTouchStart || - this.props.disableSwipeToOpen) - ) { - return; - } - - this.maybeSwiping = true; - this.touchStartX = touchStartX; - this.touchStartY = touchStartY; - - document.body.addEventListener('touchmove', this.onBodyTouchMove); - document.body.addEventListener('touchend', this.onBodyTouchEnd); - document.body.addEventListener('touchcancel', this.onBodyTouchEnd); - }; - - setPosition(translateX) { - const drawer = ReactDOM.findDOMNode(this.refs.clickAwayableElement); - const transformCSS = `translate3d(${(this.getTranslateMultiplier() * translateX)}px, 0, 0)`; - this.refs.overlay.setOpacity(1 - translateX / this.getMaxTranslateX()); - autoPrefix.set(drawer.style, 'transform', transformCSS); - } - - getTranslateX(currentX) { - return Math.min( - Math.max( - this.state.swiping === 'closing' ? - this.getTranslateMultiplier() * (currentX - this.swipeStartX) : - this.getMaxTranslateX() - this.getTranslateMultiplier() * (this.swipeStartX - currentX), - 0 - ), - this.getMaxTranslateX() - ); - } - - onBodyTouchMove = (event) => { - const currentX = event.touches[0].pageX; - const currentY = event.touches[0].pageY; - - if (this.state.swiping) { - event.preventDefault(); - this.setPosition(this.getTranslateX(currentX)); - } else if (this.maybeSwiping) { - const dXAbs = Math.abs(currentX - this.touchStartX); - const dYAbs = Math.abs(currentY - this.touchStartY); - // If the user has moved his thumb ten pixels in either direction, - // we can safely make an assumption about whether he was intending - // to swipe or scroll. - const threshold = 10; - - if (dXAbs > threshold && dYAbs <= threshold) { - this.swipeStartX = currentX; - this.setState({ - swiping: this.state.open ? 'closing' : 'opening', - }); - this.setPosition(this.getTranslateX(currentX)); - } else if (dXAbs <= threshold && dYAbs > threshold) { - this.onBodyTouchEnd(); - } - } - }; - - onBodyTouchEnd = (event) => { - if (this.state.swiping) { - const currentX = event.changedTouches[0].pageX; - const translateRatio = this.getTranslateX(currentX) / this.getMaxTranslateX(); - - this.maybeSwiping = false; - const swiping = this.state.swiping; - this.setState({ - swiping: null, - }); - - // We have to open or close after setting swiping to null, - // because only then CSS transition is enabled. - if (translateRatio > 0.5) { - if (swiping === 'opening') { - this.setPosition(this.getMaxTranslateX()); - } else { - this.close('swipe'); - } - } else { - if (swiping === 'opening') { - this.open('swipe'); - } else { - this.setPosition(0); - } - } + handleRequestChange = (open, reason) => { + if (open) { + this.open(reason); } else { - this.maybeSwiping = false; + this.close(reason); } - - document.body.removeEventListener('touchmove', this.onBodyTouchMove); - document.body.removeEventListener('touchend', this.onBodyTouchEnd); - document.body.removeEventListener('touchcancel', this.onBodyTouchEnd); }; render() { @@ -344,48 +136,39 @@ class Drawer extends Component { className, containerClassName, containerStyle, + disableSwipeToOpen, docked, openSecondary, overlayClassName, overlayStyle, style, + swipeAreaWidth, + width, zDepth, + ...other, } = this.props; - const styles = this.getStyles(); - - let overlay; - if (!docked) { - overlay = ( - <Overlay - ref="overlay" - show={this.shouldShow()} - className={overlayClassName} - style={Object.assign(styles.overlay, overlayStyle)} - transitionEnabled={!this.state.swiping} - onTouchTap={this.handleTouchTapOverlay} - /> - ); - } - return ( - <div + <SlidingSheet className={className} + closeable={!docked} + containerClassName={containerClassName} + containerStyle={Object.assign({backgroundColor: this.context.muiTheme.drawer.color}, containerStyle)} + direction={openSecondary ? 'right' : 'left'} + modal={!docked} + onRequestChange={this.handleRequestChange} + open={this.state.open} + overlayClassName={overlayClassName} + overlayStyle={overlayStyle} + primaryDimension={width || this.context.muiTheme.drawer.width} style={style} + swipeAreaWidth={swipeAreaWidth} + swipeToOpen={!docked && !disableSwipeToOpen} + zDepth={zDepth} + {...other} > - <EventListener target="window" onKeyUp={this.handleKeyUp} /> - {overlay} - <Paper - ref="clickAwayableElement" - zDepth={zDepth} - rounded={false} - transitionEnabled={!this.state.swiping} - className={containerClassName} - style={Object.assign(styles.root, openSecondary && styles.rootWhenOpenRight, containerStyle)} - > - {children} - </Paper> - </div> + {children} + </SlidingSheet> ); } } diff --git a/src/index.js b/src/index.js index 488e3d326716e9..d73b6e640f0fef 100644 --- a/src/index.js +++ b/src/index.js @@ -2,6 +2,7 @@ export AppBar from './AppBar'; export AutoComplete from './AutoComplete'; export Avatar from './Avatar'; export Badge from './Badge'; +export BottomSheet from './BottomSheet'; export Card from './Card'; export CardActions from './Card/CardActions'; export CardHeader from './Card/CardHeader'; diff --git a/src/internal/SlidingSheet.js b/src/internal/SlidingSheet.js new file mode 100644 index 00000000000000..21a0ea62762a35 --- /dev/null +++ b/src/internal/SlidingSheet.js @@ -0,0 +1,439 @@ +import React, {Component, PropTypes} from 'react'; +import {findDOMNode} from 'react-dom'; +import EventListener from 'react-event-listener'; +import keycode from 'keycode'; +import autoPrefix from '../utils/autoPrefix'; +import transitions from '../styles/transitions'; +import Overlay from '../internal/Overlay'; +import Paper from '../Paper'; +import propTypes from '../utils/propTypes'; + +let openNavEventHandler = null; + +class SlidingSheet extends Component { + static propTypes = { + /** + * The contents of the `SlidingSheet` + */ + children: PropTypes.node, + /** + * The CSS class name of the root element. + */ + className: PropTypes.string, + /** + * If true, the `SlidingSheet` can be closed by sliding, the Escape key or tapping the overlay. + */ + closeable: PropTypes.bool, + /** + * The CSS class name of the container element. + */ + containerClassName: PropTypes.string, + /** + * Override the inline-styles of the container element. + */ + containerStyle: PropTypes.object, + /** + * The direction the `SlidingSheet` should slide out from. + */ + direction: PropTypes.oneOf(['left', 'right', 'top', 'bottom']), + /** + * If true, the `SlidingSheet` will be modal, showing an overlay over the rest of the page when + * opened. + */ + modal: PropTypes.bool, + /** + * Callback function fired when the `open` state of the `SlidingSheet` is requested to be changed. + * + * @param {boolean} open If true, the `SlidingSheet` was requested to be opened. + * @param {string} reason The reason for the open or close request. Possible values are + * 'swipe' for open requests; 'clickaway' (on overlay clicks), + * 'escape' (on escape key press), and 'swipe' for close requests. + */ + onRequestChange: PropTypes.func.isRequired, + /** + * If true, the `SlidingSheet` is opened. + */ + open: PropTypes.bool.isRequired, + /** + * The CSS class name to add to the `Overlay` component that is rendered behind the `SlidingSheet`. + */ + overlayClassName: PropTypes.string, + /** + * Override the inline-styles of the `Overlay` component that is rendered behind the `SlidingSheet`. + */ + overlayStyle: PropTypes.object, + /** + * The width or height of the `SlidingSheet`, in the direction in which it should slide. + */ + primaryDimension: PropTypes.number, + /** + * Override the inline-styles of the root element. + */ + style: PropTypes.object, + /** + * The width of the area in pixels on the appropriate edge of the screen where the `SlidingSheet` + * can be swiped open from. Setting this to `null` spans that area to the entire page + * (**CAUTION!** Setting this property to `null` might cause issues with sliders and swipeable + * `Tabs`, and for vertical sheets, will interfere with scrolling: use at your own risk). + */ + swipeAreaWidth: PropTypes.number, + /** + * If true, swiping in the opposite of direction will open the `SlidingSheet`. + */ + swipeToOpen: PropTypes.bool, + /** + * The zDepth of the `SlidingSheet`. + */ + zDepth: propTypes.zDepth, + + }; + + static defaultProps = { + closeable: true, + direction: 'left', + modal: false, + swipeAreaWidth: 30, + swipeToOpen: false, + zDepth: 2, + }; + + static contextTypes = { + muiTheme: PropTypes.object.isRequired, + }; + + componentWillMount() { + this.maybeSwiping = false; + this.touchStartX = null; + this.touchStartY = null; + this.swipeStartPrimary = null; + + this.setState({ + swiping: null, + }); + } + + componentDidMount() { + this.enableSwipeHandling(); + } + + componentDidUpdate() { + this.enableSwipeHandling(); + } + + componentWillUnmount() { + this.disableSwipeHandling(); + } + + getStyles() { + const muiTheme = this.context.muiTheme; + const vertical = this.isVertical(); + + const x = this.getTranslateMultiplier() * ((this.props.open || vertical) ? 0 : this.getMaxTranslatePrimary()); + const y = this.getTranslateMultiplier() * ((this.props.open || !vertical) ? 0 : this.getMaxTranslatePrimary()); + + const height = vertical ? this.props.primaryDimension : '100%'; + const width = vertical ? '100%' : this.props.primaryDimension; + + const styles = { + root: { + height: height, + width: width, + position: 'fixed', + zIndex: muiTheme.zIndex.slidingSheet, + // Make sure a closed sheet whose dimension we don't know yet stays hidden + visibility: this.props.open || this.props.primaryDimension ? 'visible' : 'hidden', + transform: `translate3d(${x}px, ${y}px, 0)`, + transition: !this.state.swiping && transitions.easeOut(null, 'transform', null), + overflow: 'auto', + WebkitOverflowScrolling: 'touch', // iOS momentum scrolling + }, + overlay: { + zIndex: muiTheme.zIndex.slidingSheetOverlay, + pointerEvents: this.props.open ? 'auto' : 'none', // Bypass mouse events when sheet is closing. + }, + // Styles that depend on the direction set for the sheet + left: { + left: 0, + top: 0, + }, + right: { + right: 0, + top: 0, + }, + top: { + left: 0, + top: 0, + right: 0, + maxHeight: `calc(100% - ${muiTheme.spacing.desktopKeylineIncrement + muiTheme.spacing.desktopGutterMini}px)`, + }, + bottom: { + left: 0, + bottom: 0, + right: 0, + maxHeight: `calc(100% - ${muiTheme.spacing.desktopKeylineIncrement + muiTheme.spacing.desktopGutterMini}px)`, + }, + }; + + return styles; + } + + isVertical() { + const direction = this.props.direction; + return direction === 'top' || direction === 'bottom'; + } + + shouldShow() { + return this.props.open || !!this.state.swiping; // component is swiping + } + + close(reason) { + if (this.props.onRequestChange) this.props.onRequestChange(false, reason); + return this; + } + + open(reason) { + if (this.props.onRequestChange) this.props.onRequestChange(true, reason); + return this; + } + + handleTouchTapOverlay = (event) => { + event.preventDefault(); + if (this.props.closeable) this.close('clickaway'); + }; + + handleKeyUp = (event) => { + if (this.props.open && this.props.closeable && keycode(event) === 'esc') { + this.close('escape'); + } + }; + + getMaxTranslatePrimary() { + return this.props.primaryDimension + 10; + } + + getTranslateMultiplier() { + const direction = this.props.direction; + return direction === 'right' || direction === 'bottom' ? 1 : -1; + } + + enableSwipeHandling() { + if (this.props.swipeToOpen || this.props.closeable) { + document.body.addEventListener('touchstart', this.onBodyTouchStart); + if (this.props.swipeToOpen && !openNavEventHandler) { + openNavEventHandler = this.onBodyTouchStart; + } + } else { + this.disableSwipeHandling(); + } + } + + disableSwipeHandling() { + document.body.removeEventListener('touchstart', this.onBodyTouchStart); + if (openNavEventHandler === this.onBodyTouchStart) { + openNavEventHandler = null; + } + } + + onBodyTouchStart = (event) => { + const swipeAreaWidth = this.props.swipeAreaWidth; + const direction = this.props.direction; + + const touchStartX = event.touches[0].pageX; + const touchStartY = event.touches[0].pageY; + + const vertical = this.isVertical(); + + const touchStartPrimary = vertical ? touchStartY : touchStartX; + const touchStartSecondary = vertical ? touchStartX : touchStartY; + + const bodyPrimaryDimension = vertical ? document.body.offsetHeight : document.body.offsetWidth; + + // Open only if swiping from the edge while closed + if (swipeAreaWidth !== null && !this.props.open) { + if (direction === 'right' || direction === 'bottom') { + // Calculate from the bottom/right side + if (touchStartPrimary < bodyPrimaryDimension - swipeAreaWidth) return; + } else { + // Calculate from the top/left side + if (touchStartPrimary > swipeAreaWidth) return; + } + } + + if (!this.props.open && + (openNavEventHandler !== this.onBodyTouchStart || + !this.props.swipeToOpen) || + this.props.open && !this.props.closeable) { + return; + } + + // For a vertical sheet, only start swiping away if the user + // is touching the edge of the sheet + if (this.props.open && vertical) { + const sheetHeight = findDOMNode(this.refs.clickAwayableElement).offsetHeight; + const sheetEdge = direction === 'top' ? sheetHeight : window.innerHeight - sheetHeight; + const windowTouchStartY = touchStartY - window.pageYOffset; + + if (windowTouchStartY >= sheetEdge - 30 && windowTouchStartY <= sheetEdge + 30) { + // We can start swiping down right away, rather than setting this.maybeSwiping + this.swipeStartPrimary = touchStartPrimary; + this.setState({swiping: 'closing'}); + } else { + return; + } + } else { + this.maybeSwiping = true; + } + + this.touchStartPrimary = touchStartPrimary; + this.touchStartSecondary = touchStartSecondary; + + document.body.addEventListener('touchmove', this.onBodyTouchMove); + document.body.addEventListener('touchend', this.onBodyTouchEnd); + document.body.addEventListener('touchcancel', this.onBodyTouchEnd); + }; + + setPosition(translatePrimary) { + const slidingSheet = findDOMNode(this.refs.clickAwayableElement); + const vertical = this.isVertical(); + const translateX = vertical ? 0 : translatePrimary; + const translateY = vertical ? translatePrimary : 0; + const transformCSS = `translate3d(${(this.getTranslateMultiplier() * translateX)}px, ` + + `${(this.getTranslateMultiplier() * translateY)}px, 0)`; + if (this.props.modal) this.refs.overlay.setOpacity(1 - translatePrimary / this.getMaxTranslatePrimary()); + autoPrefix.set(slidingSheet.style, 'transform', transformCSS); + } + + getTranslatePrimary(currentPrimary) { + return Math.min( + Math.max( + this.state.swiping === 'closing' ? + this.getTranslateMultiplier() * (currentPrimary - this.swipeStartPrimary) : + this.getMaxTranslatePrimary() - + this.getTranslateMultiplier() * (this.swipeStartPrimary - currentPrimary), + 0 + ), + this.getMaxTranslatePrimary() + ); + } + + onBodyTouchMove = (event) => { + const currentX = event.touches[0].pageX; + const currentY = event.touches[0].pageY; + const vertical = this.isVertical(); + + const currentPrimary = vertical ? currentY : currentX; + const currentSecondary = vertical ? currentX : currentY; + + if (this.state.swiping) { + event.preventDefault(); + this.setPosition(this.getTranslatePrimary(currentPrimary)); + } else if (this.maybeSwiping) { + const dPrimaryAbs = Math.abs(currentPrimary - this.touchStartPrimary); + const dSecondaryAbs = Math.abs(currentSecondary - this.touchStartSecondary); + // If the user has moved his thumb ten pixels in either direction, + // we can safely make an assumption about whether he was intending + // to swipe or scroll in the horizontal case + const threshold = 10; + + if (dPrimaryAbs > threshold && dSecondaryAbs <= threshold) { + event.preventDefault(); + this.swipeStartPrimary = currentPrimary; + this.setState({ + swiping: this.props.open ? 'closing' : 'opening', + }); + this.setPosition(this.getTranslatePrimary(currentPrimary)); + } else if (dPrimaryAbs <= threshold && dSecondaryAbs > threshold) { + this.onBodyTouchEnd(); + } + } + }; + + onBodyTouchEnd = (event) => { + if (this.state.swiping) { + const vertical = this.isVertical(); + const currentPrimary = vertical ? event.changedTouches[0].pageY : event.changedTouches[0].pageX; + const translateRatio = this.getTranslatePrimary(currentPrimary) / this.getMaxTranslatePrimary(); + + this.maybeSwiping = false; + const swiping = this.state.swiping; + this.setState({ + swiping: null, + }); + + // We have to open or close after setting swiping to null, + // because only then CSS transition is enabled. + if (translateRatio > 0.5) { + if (swiping === 'opening') { + this.setPosition(this.getMaxTranslatePrimary()); + } else { + this.close('swipe'); + } + } else { + if (swiping === 'opening') { + this.open('swipe'); + } else { + this.setPosition(0); + } + } + } else { + this.maybeSwiping = false; + } + + document.body.removeEventListener('touchmove', this.onBodyTouchMove); + document.body.removeEventListener('touchend', this.onBodyTouchEnd); + document.body.removeEventListener('touchcancel', this.onBodyTouchEnd); + }; + + render() { + const { + children, + className, + containerClassName, + containerStyle, + direction, + modal, + overlayClassName, + overlayStyle, + style, + zDepth, + } = this.props; + + const styles = this.getStyles(); + + let overlay; + if (modal) { + overlay = ( + <Overlay + ref="overlay" + show={this.shouldShow()} + className={overlayClassName} + style={Object.assign(styles.overlay, overlayStyle)} + transitionEnabled={!this.state.swiping} + onTouchTap={this.handleTouchTapOverlay} + /> + ); + } + + return ( + <div + className={className} + style={Object.assign({width: '100%'}, style)} + > + <EventListener target="window" onKeyUp={this.handleKeyUp} /> + {overlay} + <Paper + ref="clickAwayableElement" + zDepth={zDepth} + rounded={false} + transitionEnabled={!this.state.swiping} + className={containerClassName} + style={Object.assign(styles.root, styles[direction], containerStyle)} + > + {children} + </Paper> + </div> + ); + } +} + +export default SlidingSheet; diff --git a/src/styles/getMuiTheme.js b/src/styles/getMuiTheme.js index 1bcc6d81b0496f..53294da1c1385c 100644 --- a/src/styles/getMuiTheme.js +++ b/src/styles/getMuiTheme.js @@ -49,6 +49,9 @@ export default function getMuiTheme(muiTheme, ...more) { secondaryTextColor: palette.alternateTextColor, fontWeight: typography.fontWeightMedium, }, + bottomSheet: { + color: palette.canvasColor, + }, button: { height: 36, minWidth: 88, diff --git a/src/styles/zIndex.js b/src/styles/zIndex.js index ea90ef5649e3b2..9440597025d849 100644 --- a/src/styles/zIndex.js +++ b/src/styles/zIndex.js @@ -3,6 +3,8 @@ export default { appBar: 1100, drawerOverlay: 1200, drawer: 1300, + slidingSheetOverlay: 1200, + slidingSheet: 1300, dialogOverlay: 1400, dialog: 1500, layer: 2000,