Repository: codypearce/material-bread (js)

Issue:
SheetsBottom
**Describe your feature request**

"Bottom sheets are surfaces containing supplementary content that are anchored to the bottom of the screen."

**Context**
https://material.io/design/components/sheets-bottom.html#
<img width="793" alt="Sheets: bottom - Material Design 2019-04-21 22-40-23" src="https://user-images.githubusercontent.com/12564956/56484852-7e5e1100-6486-11e9-985a-91f0693a5e5b.png">

- [ ] Create component
- [ ] Create storybook section
- [ ] Create doc page
- [ ] Create tests




Pull request:
112 sheet bottom
closes #112 

Patch:
diff --git a/docs/src/content/components/sheet-bottom/MainDemo.js b/docs/src/content/components/sheet-bottom/MainDemo.js
new file mode 100644
index 0000000..eff9d26
--- /dev/null
+++ b/docs/src/content/components/sheet-bottom/MainDemo.js
@@ -0,0 +1,70 @@
+import React from 'react';
+import { View, Image } from 'react-native';
+import { ComponentMainDemo, CodeInline } from '@components';
+import { SheetBottom, Button, List, ListItem, Icon } from '../../../../../src/';
+
+export const code = `class MainDemo extends React.Component {
+  constructor(props) {
+    super(props)
+    this.state = {
+        visible: false,
+    }
+  }
+  render() {
+    return (
+      <View style={{ flex: 1}}>
+        <Button
+          text="Open Sheet"
+          onPress={() => {
+            this.setState({ visible: true });
+          }}
+        />
+        <SheetBottom
+          visible={this.state.visible}
+          onBackdropPress={() => this.setState({ visible: false })}
+          onSwipeDown={() => this.setState({ visible: false })}>
+          <List>
+            <ListItem
+              text={'Attractions'}
+              icon={<Icon name={'local-movies'} size={24} />}
+            />
+            <ListItem
+              text={'Dining'}
+              icon={<Icon name={'local-dining'} size={24} />}
+            />
+            <ListItem
+              text={'Education'}
+              icon={<Icon name={'edit'} size={24} />}
+            />
+            <ListItem
+              text={'Health'}
+              icon={<Icon name={'favorite'} size={24} />}
+            />
+            <ListItem
+              text={'Family'}
+              icon={<Icon name={'group'} size={24} />}
+            />
+          
+          </List>
+        </SheetBottom>
+      </View>
+    );
+  }
+}`;
+
+const MainDemo = pageHref => (
+  <ComponentMainDemo
+    pageHref={pageHref}
+    description={
+      <div>
+        <CodeInline code="SheetBottom" type="element" /> animates from the
+        bottom of the screen to show more items. You can manually control swipe
+        up and down by using <CodeInline code="onSwipeUp" type="element" /> and{' '}
+        <CodeInline code="onSwipeDown" type="element" />.
+      </div>
+    }
+    code={code}
+    scope={{ View, SheetBottom, Button, List, ListItem, Icon, Image }}
+  />
+);
+export default MainDemo;
diff --git a/docs/src/content/components/sheet-bottom/Props.js b/docs/src/content/components/sheet-bottom/Props.js
new file mode 100644
index 0000000..8a8c941
--- /dev/null
+++ b/docs/src/content/components/sheet-bottom/Props.js
@@ -0,0 +1,8 @@
+import React from 'react';
+import { ComponentProps } from '@components';
+import propData from './propData';
+
+const Props = pageHref => (
+  <ComponentProps pageHref={pageHref} propData={propData} />
+);
+export default Props;
diff --git a/docs/src/content/components/sheet-bottom/Usage.js b/docs/src/content/components/sheet-bottom/Usage.js
new file mode 100644
index 0000000..9e4e671
--- /dev/null
+++ b/docs/src/content/components/sheet-bottom/Usage.js
@@ -0,0 +1,57 @@
+import React from 'react';
+import { ComponentUsage } from '@components';
+
+export const code = `import React, { Component } from 'react';
+import { SheetBottom, Button, List, ListItem, Icon  } from 'material-bread';
+
+class SelectPage extends React.Component {
+  constructor(props) {
+    super(props)
+    this.state = {
+        visible: false
+    }
+  }
+  render() {
+    return (
+      <View style={{ flex: 1}}>
+        <Button
+          text="Open Sheet"
+          onPress={() => {
+            this.setState({ visible: true });
+          }}
+        />
+        <SheetBottom
+          visible={this.state.visible}
+          onBackdropPress={() => this.setState({ visible: false })}
+          onSwipeDown={() => this.setState({ visible: false })}>
+          <List>
+            <ListItem
+              text={'Attractions'}
+              icon={<Icon name={'local-movies'} size={24} />}
+            />
+            <ListItem
+              text={'Dining'}
+              icon={<Icon name={'local-dining'} size={24} />}
+            />
+            <ListItem
+              text={'Education'}
+              icon={<Icon name={'edit'} size={24} />}
+            />
+            <ListItem
+              text={'Health'}
+              icon={<Icon name={'favorite'} size={24} />}
+            />
+            <ListItem
+              text={'Family'}
+              icon={<Icon name={'group'} size={24} />}
+            />
+          
+          </List>
+        </SheetBottom>
+      </View>
+    );
+  }
+}`;
+
+const Usage = pageHref => <ComponentUsage pageHref={pageHref} code={code} />;
+export default Usage;
diff --git a/docs/src/content/components/sheet-bottom/index.js b/docs/src/content/components/sheet-bottom/index.js
new file mode 100644
index 0000000..d6d0762
--- /dev/null
+++ b/docs/src/content/components/sheet-bottom/index.js
@@ -0,0 +1,28 @@
+import React, { Component } from 'react';
+import ComponentPageLayout from '../../../components/ComponentPage/ComponentPageLayout';
+import MainDemo from './MainDemo';
+import Usage from './Usage';
+import Props from './Props';
+
+const sections = [{ name: 'Component' }, { name: 'Usage' }, { name: 'Props' }];
+
+export default class DropdownPage extends Component {
+  render() {
+    return (
+      <div>
+        <ComponentPageLayout
+          componentName={'sheet-bottom'}
+          title={'SheetBottom'}
+          description={`Bottom sheets are surfaces containing supplementary content that are anchored to the bottom of the screen.
+          `}
+          importCode={`import { SheetBottom } from 'material-bread';`}
+          docsLink={'https://material.io/design/components/sheets-bottom.html#'}
+          sideScrollMenuItems={sections}>
+          <MainDemo pageHref={'/components/sheet-bottom'} />
+          <Usage pageHref={'/components/sheet-bottom'} />
+          <Props pageHref={'/components/sheet-bottom'} />
+        </ComponentPageLayout>
+      </div>
+    );
+  }
+}
diff --git a/docs/src/content/components/sheet-bottom/propData.js b/docs/src/content/components/sheet-bottom/propData.js
new file mode 100644
index 0000000..8f780a5
--- /dev/null
+++ b/docs/src/content/components/sheet-bottom/propData.js
@@ -0,0 +1,36 @@
+import { createTableData } from '../../../utils/createPropData';
+const propData = [
+  [
+    'cardVerticalPadding',
+    'Padding vertical on the sheet, this used in calculating the position',
+    'number',
+    '20',
+  ],
+
+  ['duration', 'How long the hide animation takes', 'number', '200ms'],
+  [
+    'onBackdropPress',
+    'Callback when mask is pressed, usually to close sheet',
+    'func',
+    '',
+  ],
+  [
+    'onSwipeDown',
+    'Callback on swiping down, usually to close sheet',
+    'func',
+    '',
+  ],
+  ['onSwipeUp', 'Callback on swiping up', 'func', ''],
+  [
+    'pageHeight',
+    "Manually control the height of the page, useful for demos or sheets that shouldn't take up the whole page",
+    'number',
+    'ScreenHeight',
+  ],
+  ['style', 'Styles sheet element', 'object', ''],
+
+  ['visible', 'Toggles Sheet animation', 'bool', ''],
+  ['wrapperStyles', 'Styles wrapper around the whole page', 'object', ''],
+];
+
+export default createTableData(propData);
diff --git a/docs/src/navigation/componentPages.js b/docs/src/navigation/componentPages.js
index c6f555c..3ecaae5 100644
--- a/docs/src/navigation/componentPages.js
+++ b/docs/src/navigation/componentPages.js
@@ -192,6 +192,10 @@ const components = [
     title: 'Select',
     path: '/components/select',
   },
+  {
+    title: 'SheetBottom',
+    path: '/components/sheet-bottom',
+  },
   {
     title: 'Snackbar',
     path: '/components/snackbar',
diff --git a/docs/src/pages/components/sheet-bottom.js b/docs/src/pages/components/sheet-bottom.js
new file mode 100644
index 0000000..04f9875
--- /dev/null
+++ b/docs/src/pages/components/sheet-bottom.js
@@ -0,0 +1,1 @@
+export default from '../../content/components/sheet-bottom';
diff --git a/src/Components/List/ListItem/ListItem.js b/src/Components/List/ListItem/ListItem.js
index 803fb67..56b83fe 100644
--- a/src/Components/List/ListItem/ListItem.js
+++ b/src/Components/List/ListItem/ListItem.js
@@ -25,7 +25,7 @@ class ListItem extends Component {
     const { text, secondaryText, disabled } = this.props;
 
     return (
-      <View>
+      <View style={{ alignItems: 'center' }}>
         <BodyText
           style={[
             styles.listItemText,
@@ -33,7 +33,11 @@ class ListItem extends Component {
           ]}>
           {text}
         </BodyText>
-        <Caption style={styles.listItemSecondaryText}>{secondaryText}</Caption>
+        {secondaryText ? (
+          <Caption style={styles.listItemSecondaryText}>
+            {secondaryText}
+          </Caption>
+        ) : null}
       </View>
     );
   }
diff --git a/src/Components/SheetBottom/SheetBottom.js b/src/Components/SheetBottom/SheetBottom.js
new file mode 100644
index 0000000..8903834
--- /dev/null
+++ b/src/Components/SheetBottom/SheetBottom.js
@@ -0,0 +1,224 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import {
+  View,
+  TouchableOpacity,
+  Animated,
+  PanResponder,
+  Modal,
+  Platform,
+  Dimensions,
+} from 'react-native';
+import styles from './SheetBottom.styles';
+import WebModal from 'modal-enhanced-react-native-web';
+
+class SheetBottom extends Component {
+  constructor(props) {
+    super(props);
+    this.createPanResponder(this.props);
+  }
+
+  static propTypes = {
+    duration: PropTypes.number,
+    onBackdropPress: PropTypes.func,
+    onSwipeDown: PropTypes.func,
+    onSwipeUp: PropTypes.func,
+    children: PropTypes.node,
+    wrapperStyles: PropTypes.object,
+    style: PropTypes.object,
+    visible: PropTypes.bool,
+    cardVerticalPadding: PropTypes.number,
+    pageHeight: PropTypes.number,
+  };
+
+  static defaultProps = {
+    duration: 200,
+    cardVerticalPadding: 20,
+  };
+
+  state = {
+    internalVisible: false,
+    animatedPosition: new Animated.Value(0),
+    pan: new Animated.ValueXY(),
+    initialHeight: 0,
+    initialWidth: 0,
+    fullHeight: 0,
+  };
+
+  componentDidMount() {
+    const { visible, pageHeight } = this.props;
+    const { pan } = this.state;
+    if (visible) {
+      this._open();
+    }
+    this.createPanResponder(this.props);
+    pan.setValue({ x: 0, y: 200 });
+    this.setState({
+      fullHeight: pageHeight ? pageHeight : Dimensions.get('window').height,
+    });
+  }
+
+  componentDidUpdate(prevProps) {
+    const { visible } = this.props;
+
+    if (visible != prevProps.visible) {
+      if (visible) {
+        this._open();
+      } else {
+        this._close();
+      }
+    }
+  }
+
+  onMenuLayout = e => {
+    const { pan, initialHeight } = this.state;
+    const { width, height } = e.nativeEvent.layout;
+    if (height == 0) return;
+    if (height <= initialHeight) return;
+
+    this.setState(
+      {
+        initialWidth: width,
+        initialHeight: height,
+      },
+      () => {
+        pan.setValue({ x: 0, y: height });
+        this.createPanResponder(this.props);
+      },
+    );
+  };
+
+  animateSheet(visible) {
+    const { cardVerticalPadding, duration } = this.props;
+    const { initialHeight, fullHeight, pan } = this.state;
+
+    if (visible) {
+      this.setState({ internalVisible: true }, () => {
+        if (initialHeight == 0) {
+          setTimeout(() => this.animateSheet(true), 0);
+          return;
+        }
+        Animated.spring(pan, {
+          toValue: {
+            x: 0,
+            y: fullHeight - initialHeight - cardVerticalPadding,
+          },
+        }).start();
+      });
+    } else {
+      Animated.timing(pan, {
+        toValue: { x: 0, y: fullHeight },
+        duration: duration,
+      }).start(() => {
+        this.setState({ internalVisible: false });
+      });
+    }
+  }
+
+  createPanResponder = () => {
+    const { onSwipeDown, onSwipeUp, cardVerticalPadding } = this.props;
+    const { pan, initialHeight, fullHeight } = this.state;
+
+    this.panResponder = PanResponder.create({
+      onStartShouldSetPanResponder: () => true,
+      onPanResponderMove: (e, gestureState) => {
+        if (gestureState.dy < 80 && gestureState.dy > -40) {
+          Animated.timing(pan, {
+            toValue: {
+              x: 0,
+              y:
+                fullHeight -
+                initialHeight -
+                cardVerticalPadding +
+                gestureState.dy,
+            },
+            duration: 0,
+          }).start();
+        }
+      },
+      onPanResponderRelease: (e, gestureState) => {
+        if (initialHeight / 4 - gestureState.dy < 0) {
+          if (onSwipeDown) onSwipeDown();
+        } else if (initialHeight / 4 + gestureState.dy > 0) {
+          if (onSwipeUp) onSwipeUp();
+        }
+      },
+    });
+  };
+
+  _open() {
+    this.animateSheet(true);
+  }
+
+  _close = () => {
+    this.animateSheet(false);
+  };
+
+  _onBackdropPress = () => {
+    const { onBackdropPress } = this.props;
+
+    if (onBackdropPress) onBackdropPress();
+  };
+
+  _handlePlatform() {
+    const { internalVisible } = this.state;
+    if (Platform.OS == 'web') {
+      return (
+        <WebModal
+          transparent
+          animationType={'none'}
+          visible={internalVisible}
+          onRequestClose={this._close}
+          style={{ margin: 0 }}>
+          {this._renderContent()}
+        </WebModal>
+      );
+    } else {
+      return (
+        <Modal
+          transparent
+          animationType={'none'}
+          visible={internalVisible}
+          onRequestClose={this._close}>
+          {this._renderContent()}
+        </Modal>
+      );
+    }
+  }
+
+  _renderContent() {
+    const { children, wrapperStyles, style, cardVerticalPadding } = this.props;
+    const { pan } = this.state;
+
+    return (
+      <View style={[styles.wrapper, wrapperStyles]}>
+        <TouchableOpacity
+          style={styles.mask}
+          activeOpacity={1}
+          onPress={this._onBackdropPress}
+        />
+
+        <Animated.View
+          {...this.panResponder.panHandlers}
+          style={[
+            styles.container,
+            style,
+
+            {
+              height: '100%',
+              paddingVertical: cardVerticalPadding,
+              transform: [{ translateY: pan.y }],
+            },
+          ]}>
+          <View onLayout={this.onMenuLayout}>{children}</View>
+        </Animated.View>
+      </View>
+    );
+  }
+
+  render() {
+    return this._handlePlatform();
+  }
+}
+
+export default SheetBottom;
diff --git a/src/Components/SheetBottom/SheetBottom.stories.js b/src/Components/SheetBottom/SheetBottom.stories.js
new file mode 100644
index 0000000..6cedbc0
--- /dev/null
+++ b/src/Components/SheetBottom/SheetBottom.stories.js
@@ -0,0 +1,68 @@
+import React from 'react';
+import { View, Platform } from 'react-native';
+
+import { SheetBottom, Button, List, ListItem, Icon } from '../../';
+import { State, Store } from '@sambego/storybook-state';
+import Header from '../../storybook/components/Header';
+import Container from '../../storybook/components/Container';
+import { storiesOf } from '../../storybook/helpers/storiesOf';
+
+const store = new Store({
+  visible: false,
+});
+
+export default storiesOf('Components|SheetBottom', module)
+  .addParameters({ jest: ['SheetBottom'] })
+  .add('Modal', () => (
+    <Container>
+      <Header title={'SheetBottom'} />
+      <State store={store} style={{ flex: 1 }}>
+        {state => (
+          <View style={{ flex: 1, height: 400 }}>
+            <Button
+              text="Open Sheet"
+              onPress={() => {
+                store.set({ visible: true });
+              }}
+            />
+            <SheetBottom
+              visible={state.visible}
+              pageHeight={Platform.OS == 'web' ? 600 : null}
+              onBackdropPress={() => store.set({ visible: false })}
+              onSwipeDown={() => store.set({ visible: false })}>
+              <List>
+                <ListItem
+                  text={'Attractions'}
+                  icon={<Icon name={'local-movies'} size={24} />}
+                />
+                <ListItem
+                  text={'Dining'}
+                  icon={<Icon name={'local-dining'} size={24} />}
+                />
+                <ListItem
+                  text={'Education'}
+                  icon={<Icon name={'edit'} size={24} />}
+                />
+                <ListItem
+                  text={'Health'}
+                  icon={<Icon name={'favorite'} size={24} />}
+                />
+                <ListItem
+                  text={'Family'}
+                  icon={<Icon name={'group'} size={24} />}
+                />
+                <ListItem
+                  text={'Office'}
+                  icon={<Icon name={'content-cut'} size={24} />}
+                />
+                <ListItem
+                  text={'Promotions'}
+                  icon={<Icon name={'loyalty'} size={24} />}
+                />
+              </List>
+            </SheetBottom>
+          </View>
+        )}
+      </State>
+    </Container>
+  ));
diff --git a/src/Components/SheetBottom/SheetBottom.styles.js b/src/Components/SheetBottom/SheetBottom.styles.js
new file mode 100644
index 0000000..97184c3
--- /dev/null
+++ b/src/Components/SheetBottom/SheetBottom.styles.js
@@ -0,0 +1,26 @@
+import { StyleSheet } from 'react-native';
+
+const styles = StyleSheet.create({
+  wrapper: {
+    flex: 1,
+    backgroundColor: '#00000077',
+  },
+  mask: {
+    backgroundColor: 'transparent',
+    width: '100%',
+    height: '100%',
+    zIndex: -1,
+    position: 'absolute',
+    top: 0,
+    left: 0,
+  },
+  container: {
+    backgroundColor: '#fff',
+    width: '100%',
+    overflow: 'hidden',
+    paddingVertical: 20,
+    zIndex: 1000,
+  },
+});
+
+export default styles;
diff --git a/src/index.js b/src/index.js
index 9b43dac..eb8d23f 100644
--- a/src/index.js
+++ b/src/index.js
@@ -85,6 +85,7 @@ export {
   default as Searchfield,
 } from './Components/TextField/Searchfield/Searchfield';
 export { default as Searchbar } from './Components/Appbar/Searchbar/Searchbar';
+export { default as SheetBottom } from './Components/SheetBottom/SheetBottom';
 export { default as Snackbar } from './Components/Snackbar/Snackbar';
 export { default as Switch } from './Components/Switch/Switch';
 export { default as TextField } from './Components/TextField/TextField';
diff --git a/src/storybook/stories/index.js b/src/storybook/stories/index.js
index c40c697..423a4d1 100644
--- a/src/storybook/stories/index.js
+++ b/src/storybook/stories/index.js
@@ -114,6 +114,10 @@ export {
 export {
   default as SelectOutlined,
 } from '../../Components/Select/SelectOutlined/SelectOutlined.stories.js';
+
+export {
+  default as SheetBottom,
+} from '../../Components/SheetBottom/SheetBottom.stories.js';
 export {
   default as Snackbar,
 } from '../../Components/Snackbar/Snackbar.stories.js';


Test patch:
diff --git a/src/Components/SheetBottom/SheetBottom.test.js b/src/Components/SheetBottom/SheetBottom.test.js
new file mode 100644
index 0000000..3ec8e65
--- /dev/null
+++ b/src/Components/SheetBottom/SheetBottom.test.js
@@ -0,0 +1,10 @@
+import React from 'react';
+import { SheetBottom } from '../../';
+
+import ShallowRenderer from 'react-test-renderer/shallow';
+const renderer = new ShallowRenderer();
+
+test('SheetBottom Renders', () => {
+  const tree = renderer.render(<SheetBottom />);
+  expect(tree).toMatchSnapshot();
+});
diff --git a/src/Components/SheetBottom/__snapshots__/SheetBottom.test.js.snap b/src/Components/SheetBottom/__snapshots__/SheetBottom.test.js.snap
new file mode 100644
index 0000000..87264c2
--- /dev/null
+++ b/src/Components/SheetBottom/__snapshots__/SheetBottom.test.js.snap
@@ -0,0 +1,78 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`SheetBottom Renders 1`] = `
+<Component
+  animationType="none"
+  hardwareAccelerated={false}
+  onRequestClose={[Function]}
+  transparent={true}
+  visible={false}
+>
+  <View
+    style={
+      Array [
+        Object {
+          "backgroundColor": "#00000077",
+          "flex": 1,
+        },
+        undefined,
+      ]
+    }
+  >
+    <TouchableOpacity
+      activeOpacity={1}
+      onPress={[Function]}
+      style={
+        Object {
+          "backgroundColor": "transparent",
+          "height": "100%",
+          "left": 0,
+          "position": "absolute",
+          "top": 0,
+          "width": "100%",
+          "zIndex": -1,
+        }
+      }
+    />
+    <AnimatedComponent
+      onMoveShouldSetResponder={[Function]}
+      onMoveShouldSetResponderCapture={[Function]}
+      onResponderEnd={[Function]}
+      onResponderGrant={[Function]}
+      onResponderMove={[Function]}
+      onResponderReject={[Function]}
+      onResponderRelease={[Function]}
+      onResponderStart={[Function]}
+      onResponderTerminate={[Function]}
+      onResponderTerminationRequest={[Function]}
+      onStartShouldSetResponder={[Function]}
+      onStartShouldSetResponderCapture={[Function]}
+      style={
+        Array [
+          Object {
+            "backgroundColor": "#fff",
+            "overflow": "hidden",
+            "paddingVertical": 20,
+            "width": "100%",
+            "zIndex": 1000,
+          },
+          undefined,
+          Object {
+            "height": "100%",
+            "paddingVertical": 20,
+            "transform": Array [
+              Object {
+                "translateY": 0,
+              },
+            ],
+          },
+        ]
+      }
+    >
+      <View
+        onLayout={[Function]}
+      />
+    </AnimatedComponent>
+  </View>
+</Component>
+`;