diff --git a/package.json b/package.json index 6ab2d31dd..d29147d54 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "@material/notched-outline": "^0.41.0", "@material/ripple": "^0.41.0", "@material/select": "^0.40.1", + "@material/snackbar": "^0.43.0", "@material/switch": "^0.41.0", "@material/tab": "^0.41.0", "@material/tab-bar": "^0.41.0", diff --git a/packages/snackbar/README.md b/packages/snackbar/README.md new file mode 100644 index 000000000..dc4246dab --- /dev/null +++ b/packages/snackbar/README.md @@ -0,0 +1,91 @@ +# React Snackbar + +A React version of an [MDC Snackbar](https://github.com/material-components/material-components-web/tree/master/packages/mdc-snackbar). + +## Installation + +``` +npm install @material/react-snackbar +``` + +## Usage + +### Styles + +with Sass: +```js +import '@material/react-snackbar/index.scss'; +``` + +with CSS: +```js +import '@material/react-snackbar/dist/snackbar.css'; +``` + +### Javascript Instantiation +```js +import React from 'react'; +import Snackbar from '@material/react-snackbar'; + +class MyApp extends React.Component { + render() { + return ( + + ); + } +} +``` + +## Props + +Prop Name | Type | Description +--- | --- | --- +message | String | Message to show in the snackbar +className | String | Classes to be applied to the root element. +timeoutMs | Number | Timeout in milliseconds when to close snackbar. +closeOnEscape | Boolean | Closes popup on "Esc" button if true. +actionText | String | Text for action button +leading | Boolean | Shows snackbar on the left if true (or right for rtl languages) +stacked | Boolean | Shows buttons under text if true +onAnnounce | Function() => void | Callback for handling screenreader announce event +onOpening | Function() => void | Callback for handling event, which happens before opening +onOpen | Function(evt: Event) => void | Callback for handling event, which happens after opening +onClosing | Function() => void | Callback for handling event, which happens before closing +onClose | Function() => void | Callback for handling event, which happens after closing + +## Getting snackbar parameters + +If you need to get the `timeoutMs`, `closeOnEscape`, or `open` value, then you can use a ref like so: + +```js +import React from 'react'; +import Snackbar from '@material/react-snackbar'; + class MyApp extends React.Component { + getSnackbarInfo = (snackbar) => { + if (!snackbar) return; + console.log(snackbar.getTimeoutMs()); + console.log(snackbar.isOpen()); + console.log(snackbar.getCloseOnEscape()); + } + render() { + return ( + + ); + } +} +``` + +## Sass Mixins + +Sass mixins may be available to customize various aspects of the Components. Please refer to the +MDC Web repository for more information on what mixins are available, and how to use them. + +[Advanced Sass Mixins](https://github.com/material-components/material-components-web/blob/master/packages/mdc-snackbar/README.md#sass-mixins) + +## Usage with Icons + +Please see our [Best Practices doc](../../docs/best-practices.md#importing-font-icons) when importing or using icon fonts. diff --git a/packages/snackbar/index.scss b/packages/snackbar/index.scss new file mode 100644 index 000000000..2eefaf7ef --- /dev/null +++ b/packages/snackbar/index.scss @@ -0,0 +1,23 @@ +// The MIT License +// +// Copyright (c) 2018 Google, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +@import "@material/snackbar/mdc-snackbar"; diff --git a/packages/snackbar/index.tsx b/packages/snackbar/index.tsx new file mode 100644 index 000000000..34c1d2543 --- /dev/null +++ b/packages/snackbar/index.tsx @@ -0,0 +1,179 @@ +// The MIT License +// +// Copyright (c) 2019 Google, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import * as React from 'react'; +import classnames from 'classnames'; + +// TODO: replace with MDC Web types when available +import {IMDCSnackbarAdapter, IMDCSnackbarFoundation} from './types'; + +// @ts-ignore no .d.ts file +import {MDCSnackbarFoundation} from '@material/snackbar'; + +export interface Props { + message: string; + className?: string; + timeoutMs?: number; + closeOnEscape?: boolean; + actionText?: string; + leading?: boolean; + stacked?: boolean; + open?: boolean; + onOpening?: () => void; + onOpen?: () => void; + onClosing?: (reason: string) => void; + onClose?: (reason: string) => void; + onAnnounce?: () => void; +}; + +type State = { + classes: Set, +}; + +export class Snackbar extends React.Component { + foundation: IMDCSnackbarFoundation + + static defaultProps: Partial = { + open: true, + stacked: false, + leading: false, + } + + constructor(props: Props) { + super(props); + const {timeoutMs, closeOnEscape, leading, stacked} = this.props; + const classes = new Set(); + if (leading) { + classes.add('mdc-snackbar--leading'); + } + + if (stacked) { + classes.add('mdc-snackbar--stacked'); + } + + this.state = { + classes, + }; + + this.foundation = new MDCSnackbarFoundation(this.adapter); + if (timeoutMs) { + this.foundation.setTimeoutMs(timeoutMs); + } + + if (closeOnEscape) { + this.foundation.setCloseOnEscape(closeOnEscape); + } + } + get adapter(): IMDCSnackbarAdapter { + return { + addClass: (className: string) => { + const {classes} = this.state; + classes.add(className); + this.setState({ + classes, + }); + }, + removeClass: (className: string) => { + const {classes} = this.state; + classes.delete(className); + this.setState({ + classes, + }); + }, + announce: () => { + // Usually it works automatically if this component uses conditional rendering + this.props.onAnnounce && this.props.onAnnounce(); + }, + notifyOpening: () => { + const {onOpening} = this.props; + if (onOpening) { + onOpening(); + } + }, + notifyOpened: () => { + const {onOpen} = this.props; + if (onOpen) { + onOpen(); + } + }, + notifyClosing: (reason: string) => { + const {onClosing} = this.props; + if (onClosing) { + onClosing(reason); + } + }, + notifyClosed: (reason: string) => { + const {onClose} = this.props; + if (onClose) { + onClose(reason); + } + }, + }; + } + close(action: string) { + this.foundation.close(action); + } + getTimeoutMs() { + return this.foundation.getTimeoutMs(); + } + getCloseOnEscape() { + return this.foundation.getCloseOnEscape(); + } + isOpen() { + return this.foundation.isOpen(); + } + handleKeyDown = (e: React.KeyboardEvent) => { + this.foundation.handleKeyDown(e.nativeEvent); + } + handleActionClick = (e: React.MouseEvent) => { + this.foundation.handleActionButtonClick(e.nativeEvent); + } + componentDidMount() { + this.foundation.init(); + if (this.props.open) { + this.foundation.open(); + } + } + componentWillUnmount() { + this.foundation.destroy(); + } + get classes() { + return classnames(this.props.className, 'mdc-snackbar', ...Array.from(this.state.classes)); + } + render() { + return
+
+
+ {this.props.message} +
+ {this.props.actionText ? +
+ +
: null} +
+
; + } +} diff --git a/packages/snackbar/package.json b/packages/snackbar/package.json new file mode 100644 index 000000000..10f0ffefb --- /dev/null +++ b/packages/snackbar/package.json @@ -0,0 +1,26 @@ +{ + "name": "@material/react-snackbar", + "version": "0.0.0", + "description": "Material Components React Snackbar", + "license": "MIT", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "keywords": [ + "mdc web react", + "material components react", + "material design", + "material snackbar", + "materialsnackbar" + ], + "repository": { + "type": "git", + "url": "https://github.com/material-components/material-components-web-react.git" + }, + "dependencies": { + "classnames": "^2.2.5", + "react": "^16.4.2" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/snackbar/types.tsx b/packages/snackbar/types.tsx new file mode 100644 index 000000000..31964ea71 --- /dev/null +++ b/packages/snackbar/types.tsx @@ -0,0 +1,49 @@ +// The MIT License +// +// Copyright (c) 2019 Google, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +// TODO: remove this when MDC Web types are added. + +export interface IMDCSnackbarAdapter { + addClass(className: string): void + removeClass(className: string): void + announce(): void + notifyOpening(): void + notifyOpened(): void + notifyClosing(reason: string): void + notifyClosed(reason: string): void +} + +export interface IMDCSnackbarFoundation { + open(): void; + close(action: string): void; + isOpen(): boolean + getTimeoutMs(): number + setTimeoutMs(timeoutMs: number): void + getCloseOnEscape(): boolean + setCloseOnEscape(closeOnEscape: boolean): void + handleKeyDown(event: KeyboardEvent): void + handleActionButtonClick(event: MouseEvent): void + handleActionIconClick(event: MouseEvent): void + init(): void + destroy(): void + adapter_: IMDCSnackbarAdapter +} diff --git a/scripts/package-json-reader.js b/scripts/package-json-reader.js index 994e555a7..7d3b1da27 100644 --- a/scripts/package-json-reader.js +++ b/scripts/package-json-reader.js @@ -15,7 +15,7 @@ const readMaterialPackages = () => { } } return dependencies; -} +}; module.exports = {readMaterialPackages}; diff --git a/test/screenshot/golden.json b/test/screenshot/golden.json index c7bb42e82..c5c0c3671 100644 --- a/test/screenshot/golden.json +++ b/test/screenshot/golden.json @@ -15,6 +15,7 @@ "notched-outline": "7770dd381c27608a1f43b6f83da92507fe53963f5e4409bd73184b86275538fe", "radio": "adfce0bbfa2711c67a52e1c3c3c7e980314be0147e340dac733152c80c385765", "select": "10b82843806ddc961e85192d231ba5c3bc5aef7c20c14ffda7dd1e857b5a8c9f", + "snackbar": "fb7f6f61f37095d7e7c92c9305eb797145d8e72c0c8dbc4e531f9aca91b0fd28", "switch": "dd8a3ec00447e0c586b5bbefdc633681d29e6f04ff8b517a68209bd1f4a6a4e4", "tab": "0e53fa0ca9b2de4ff7941169a9b6a929a83b18e517c18404adeb40f7e644a2f1", "tab-bar": "6c28ec268b2baf308459e7df9d7471fb7907b6473240b9a28a81be54a335f932", diff --git a/test/screenshot/screenshot-test-urls.tsx b/test/screenshot/screenshot-test-urls.tsx index 07b190964..672044b87 100644 --- a/test/screenshot/screenshot-test-urls.tsx +++ b/test/screenshot/screenshot-test-urls.tsx @@ -19,6 +19,7 @@ const urls = [ 'notched-outline', 'radio', 'select', + 'snackbar', 'tab', 'tab-bar', 'tab-indicator', diff --git a/test/screenshot/snackbar/index.scss b/test/screenshot/snackbar/index.scss new file mode 100644 index 000000000..b17367c8d --- /dev/null +++ b/test/screenshot/snackbar/index.scss @@ -0,0 +1,8 @@ +.snackbar-container { + margin-bottom: 16px; + // by default snackbar is displayed in the screen bottom. + // for screenshots we need to display snackbar inside its parent + .mdc-snackbar.mdc-snackbar { + position: static; + } +} diff --git a/test/screenshot/snackbar/index.tsx b/test/screenshot/snackbar/index.tsx new file mode 100644 index 000000000..5e02ef739 --- /dev/null +++ b/test/screenshot/snackbar/index.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import '../../../packages/snackbar/index.scss'; +import './index.scss'; +import {Snackbar} from '../../../packages/snackbar/index'; + +const ButtonScreenshotTest = () => { + return ( +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ ); +}; +export default ButtonScreenshotTest; diff --git a/test/unit/snackbar/index.test.tsx b/test/unit/snackbar/index.test.tsx new file mode 100644 index 000000000..229ab7dc9 --- /dev/null +++ b/test/unit/snackbar/index.test.tsx @@ -0,0 +1,119 @@ +import * as React from 'react'; +import * as td from 'testdouble'; +import {assert} from 'chai'; +import {shallow} from 'enzyme'; +import {Snackbar} from '../../../packages/snackbar/index'; + +suite('Snackbar'); + +test('classNames adds classes', () => { + const wrapper = shallow(); + assert.isTrue(wrapper.hasClass('test-class-name')); + assert.isTrue(wrapper.hasClass('mdc-snackbar')); + wrapper.unmount(); +}); + +test('does not render actions block if no actions sent', () => { + const wrapper = shallow(); + assert.equal(wrapper.find('.mdc-snackbar__actions').length, 0); + wrapper.unmount(); +}); + +test('sets timeoutMs', () => { + const wrapper = shallow(); + assert.equal(wrapper.instance().getTimeoutMs(), 5000); + wrapper.unmount(); +}); + +test('sets timeoutMs', () => { + const wrapper = shallow(); + assert.equal(wrapper.instance().getCloseOnEscape(), true); + wrapper.unmount(); +}); + +test('renders actions', () => { + const wrapper = shallow(); + assert.equal(wrapper.find('.mdc-snackbar__actions').length, 1); + assert.equal(wrapper.find('.mdc-snackbar__action').length, 1); + wrapper.unmount(); +}); + +test('renders leading actions', () => { + const wrapper = shallow(); + assert.isTrue(wrapper.hasClass('mdc-snackbar')); + assert.isTrue(wrapper.hasClass('mdc-snackbar--leading')); + wrapper.unmount(); +}); + +test('renders stacked actions', () => { + const wrapper = shallow(); + assert.isTrue(wrapper.hasClass('mdc-snackbar')); + assert.isTrue(wrapper.hasClass('mdc-snackbar--stacked')); + wrapper.unmount(); +}); + +test('opening notification works', () => { + const openingHandler = td.func<() => void>(); + const wrapper = shallow( + ); + wrapper.instance().foundation.adapter_.notifyOpening(); + td.verify(openingHandler(), {times: 1}); + wrapper.unmount(); +}); + +test('open notification works', () => { + const openHandler = td.func<() => void>(); + const wrapper = shallow( + ); + wrapper.instance().foundation.adapter_.notifyOpened(); + td.verify(openHandler(), {times: 1}); + wrapper.unmount(); +}); + +test('closing notification works', () => { + const closingHandler = td.func<(reason: string) => void>(); + const wrapper = shallow( + ); + wrapper.instance().foundation.adapter_.notifyClosing('unit_test'); + td.verify(closingHandler('unit_test'), {times: 1}); + wrapper.unmount(); +}); + +test('close notification works', () => { + const closeHandler = td.func<(reason: string) => void>(); + const wrapper = shallow( + ); + wrapper.instance().foundation.adapter_.notifyClosed('unit_test'); + td.verify(closeHandler('unit_test'), {times: 1}); + wrapper.unmount(); +}); + +test('close method works', () => { + const wrapper = shallow(); + wrapper.instance().close('unit_test'); + assert.equal(wrapper.instance().isOpen(), false); + wrapper.unmount(); +}); + +test('announce works', () => { + const announceHandler = td.func<() => void>(); + const wrapper = shallow(); + td.verify(announceHandler(), {times: 1}); + wrapper.unmount(); +}); + +test('handleKeyDown method works', () => { + const wrapper = shallow(); + wrapper.simulate('keydown', { + nativeEvent: {}, + }); + wrapper.unmount(); +}); + +test('handleActionClick method works', () => { + const wrapper = shallow(); + wrapper.find('.mdc-snackbar__action').simulate('click', { + nativeEvent: {}, + }); + wrapper.unmount(); +});