diff --git a/docs/assets/screenshots/snackbar.gif b/docs/assets/screenshots/snackbar.gif new file mode 100644 index 0000000000..4d9cad8681 Binary files /dev/null and b/docs/assets/screenshots/snackbar.gif differ diff --git a/docs/package.json b/docs/package.json index 462568e8ad..63cb705ea9 100644 --- a/docs/package.json +++ b/docs/package.json @@ -22,7 +22,7 @@ "license": "MIT", "dependencies": { "color": "^2.0.1", - "component-docs": "^0.11.5", + "component-docs": "^0.11.6", "linaria": "^0.5.0" } } diff --git a/docs/yarn.lock b/docs/yarn.lock index 7d8ebb069a..310b0d96cd 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -1552,9 +1552,9 @@ commondir@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" -component-docs@^0.11.5: - version "0.11.5" - resolved "https://registry.yarnpkg.com/component-docs/-/component-docs-0.11.5.tgz#c02749360f026267240a7b1d3d888469b4fb4019" +component-docs@^0.11.6: + version "0.11.6" + resolved "https://registry.yarnpkg.com/component-docs/-/component-docs-0.11.6.tgz#1e46f6385679afe4aa43e5fc275c311fea773538" dependencies: babel-core "^6.26.0" babel-loader "^7.1.4" diff --git a/example/src/ExampleList.js b/example/src/ExampleList.js index 7dd037c3ef..392bd1a97e 100644 --- a/example/src/ExampleList.js +++ b/example/src/ExampleList.js @@ -18,6 +18,7 @@ import RadioButtonExample from './RadioButtonExample'; import RadioButtonGroupExample from './RadioButtonGroupExample'; import RippleExample from './RippleExample'; import SearchbarExample from './SearchbarExample'; +import SnackbarExample from './SnackbarExample'; import SwitchExample from './SwitchExample'; import TextExample from './TextExample'; import TextInputExample from './TextInputExample'; @@ -44,6 +45,7 @@ export const examples = { radioGroup: RadioButtonGroupExample, ripple: RippleExample, searchbar: SearchbarExample, + snackbar: SnackbarExample, switch: SwitchExample, text: TextExample, textInput: TextInputExample, diff --git a/example/src/SnackbarExample.js b/example/src/SnackbarExample.js new file mode 100644 index 0000000000..9f7603ccc8 --- /dev/null +++ b/example/src/SnackbarExample.js @@ -0,0 +1,64 @@ +/* @flow */ + +import * as React from 'react'; +import { StyleSheet, View } from 'react-native'; +import { Snackbar, Colors, withTheme, Button } from 'react-native-paper'; +import type { Theme } from 'react-native-paper/types'; + +type Props = { + theme: Theme, +}; + +type State = { + visible: boolean, +}; + +class SnackbarExample extends React.Component { + static title = 'Snackbar'; + + state = { + visible: false, + }; + + render() { + const { + theme: { + colors: { background }, + }, + } = this.props; + return ( + + + this.setState({ visible: false })} + action={{ + label: 'Undo', + onPress: () => { + // Do something + }, + }} + duration={Snackbar.DURATION_INDEFINITE} + > + Hey there! I'm a Snackbar. + + + ); + } +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: Colors.grey200, + alignItems: 'center', + justifyContent: 'center', + }, +}); + +export default withTheme(SnackbarExample); diff --git a/src/components/Snackbar.js b/src/components/Snackbar.js new file mode 100644 index 0000000000..d09141dc0a --- /dev/null +++ b/src/components/Snackbar.js @@ -0,0 +1,304 @@ +/* @flow */ + +import * as React from 'react'; +import { + StyleSheet, + Animated, + Text, + View, + TouchableWithoutFeedback, +} from 'react-native'; + +import ThemedPortal from './Portal/ThemedPortal'; +import withTheme from '../core/withTheme'; +import { white } from '../styles/colors'; +import type { Theme } from '../types'; + +type Props = { + /** + * Whether the Snackbar is currently visible. + */ + visible: boolean, + /** + * Label and press callback for the action button. It should contains following properties: + * - `label` - Label of the action button + * - `onPress` - Callback that is called when action button is pressed. + */ + action?: { + label: string, + onPress: () => mixed, + }, + /** + * The duration for which the Snackbar is shown. + */ + duration?: number, + /** + * Callback called when Snackbar is dismissed. The `visible` prop needs to be updated when this is called. + */ + onDismiss: () => mixed, + /** + * Text content of the Snackbar. + */ + children: React.Node, + style?: any, + /** + * @optional + */ + theme: Theme, +}; + +type State = { + rendered: boolean, + height: number, + opacity: Animated.Value, + translateY: Animated.Value, +}; + +const SNACKBAR_ANIMATION_DURATION = 250; + +/** + * Snackbar provide brief feedback about an operation through a message at the bottom of the screen. + * + *
+ * + *
+ * + * ## Usage + * ```js + * import React from 'react'; + * import { Snackbar, StyleSheet } from 'react-native-paper'; + * + * export default class MyComponent extends React.Component { + * state = { + * visible: false, + * }; + * + * render() { + * const { visible } = this.state; + * return ( + * + * + * this.setState({ visible: false })} + * action={{ + * label: 'Undo', + * onPress: () => { + * // Do something + * }, + * }} + * > + * Hey there! I'm a Snackbar. + * + * + * ); + * } + * } + * + * const styles = StyleSheet.create({ + * container: { + * flex: 1, + * justifyContent: 'space-between', + * }, + * }); + * ``` + */ +class Snackbar extends React.Component { + /** + * Show the Snackbar for a short duration. + */ + static DURATION_SHORT = 2000; + + /** + * Show the Snackbar for a long duration. + */ + static DURATION_LONG = 3500; + + /** + * Show the Snackbar for indefinite amount of time. + */ + static DURATION_INDEFINITE = Infinity; + + static defaultProps = { + duration: this.DURATION_LONG, + }; + + state = { + rendered: false, + height: 0, + opacity: new Animated.Value(0), + translateY: new Animated.Value(0), + }; + + componentDidMount() { + if (this.props.visible) { + this._show(); + } + } + + componentDidUpdate(prevProps) { + if (prevProps.visible !== this.props.visible) { + if (this.props.visible) { + this._show(); + } else { + this._hide(); + } + } + } + + componentWillUnmount() { + clearTimeout(this._hideTimeout); + } + + _hideTimeout: TimeoutID; + + _handleLayout = e => { + const { height } = e.nativeEvent.layout; + + this.setState({ + height, + rendered: true, + }); + + this.state.translateY.setValue(height); + }; + + _show = () => { + Animated.parallel([ + Animated.timing(this.state.opacity, { + toValue: 1, + duration: SNACKBAR_ANIMATION_DURATION, + useNativeDriver: true, + }), + Animated.timing(this.state.translateY, { + toValue: 0, + duration: SNACKBAR_ANIMATION_DURATION, + useNativeDriver: true, + }), + ]).start(() => { + const { duration } = this.props; + + if (duration !== Snackbar.DURATION_INDEFINITE) { + this._hideTimeout = setTimeout(this.props.onDismiss, duration); + } + }); + }; + + _hide = () => { + clearTimeout(this._hideTimeout); + + Animated.parallel([ + Animated.timing(this.state.opacity, { + toValue: 0, + duration: SNACKBAR_ANIMATION_DURATION, + useNativeDriver: true, + }), + Animated.timing(this.state.translateY, { + toValue: this.state.height, + duration: SNACKBAR_ANIMATION_DURATION, + useNativeDriver: true, + }), + ]).start(); + }; + + render() { + const { children, action, onDismiss, theme, style } = this.props; + const { fonts, colors } = theme; + + const buttonMargin = action ? 24 : 0; + const contentRightMargin = action ? 0 : 24; + + return ( + + + + + {children} + + {action ? ( + + { + action.onPress(); + onDismiss(); + }} + > + + + {action.label.toUpperCase()} + + + + + ) : null} + + + + ); + } +} + +const styles = StyleSheet.create({ + wrapper: { + backgroundColor: '#323232', + position: 'absolute', + bottom: 0, + width: '100%', + elevation: 6, + }, + container: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + content: { + color: white, + marginLeft: 24, + marginVertical: 14, + flexWrap: 'wrap', + flex: 1, + }, +}); + +export default withTheme(Snackbar); diff --git a/src/components/__tests__/Snackbar.test.js b/src/components/__tests__/Snackbar.test.js new file mode 100644 index 0000000000..0d8d2db46d --- /dev/null +++ b/src/components/__tests__/Snackbar.test.js @@ -0,0 +1,69 @@ +/* @flow */ + +import * as React from 'react'; +import renderer from 'react-test-renderer'; +import { Text } from 'react-native'; +import Snackbar from '../Snackbar'; +import PortalHost from '../Portal/PortalHost'; + +jest.useFakeTimers(); + +it('renders snackbar with content', () => { + const tree = renderer + .create( + + + Snackbar content + + + ) + .toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders not visible snackbar with content', () => { + const tree = renderer + .create( + + + Snackbar content + + + ) + .toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders snackbar with Text as a child', () => { + const tree = renderer + .create( + + + Snackbar content + + + ) + .toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders snackbar with action button', () => { + const tree = renderer + .create( + + {}} + action={{ label: 'Undo', onPress: jest.fn() }} + > + Snackbar content + + + ) + .toJSON(); + + expect(tree).toMatchSnapshot(); +}); diff --git a/src/components/__tests__/__snapshots__/Snackbar.test.js.snap b/src/components/__tests__/__snapshots__/Snackbar.test.js.snap new file mode 100644 index 0000000000..570792c080 --- /dev/null +++ b/src/components/__tests__/__snapshots__/Snackbar.test.js.snap @@ -0,0 +1,373 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders not visible snackbar with content 1`] = ` + + + + + + Snackbar content + + + + + +`; + +exports[`renders snackbar with Text as a child 1`] = ` + + + + + + + Snackbar content + + + + + + +`; + +exports[`renders snackbar with action button 1`] = ` + + + + + + Snackbar content + + + + + UNDO + + + + + + + +`; + +exports[`renders snackbar with content 1`] = ` + + + + + + Snackbar content + + + + + +`; diff --git a/src/index.js b/src/index.js index 2d3d55e846..49b6e93078 100644 --- a/src/index.js +++ b/src/index.js @@ -37,6 +37,7 @@ export { default as RadioButton } from './components/RadioButton'; export { default as RadioButtonGroup } from './components/RadioButtonGroup'; export { default as Searchbar } from './components/Searchbar'; export { default as SearchBar } from './components/Searchbar'; +export { default as Snackbar } from './components/Snackbar'; export { default as Switch } from './components/Switch'; export { default as Toolbar } from './components/Toolbar/Toolbar'; export { default as ToolbarAction } from './components/Toolbar/ToolbarAction';