diff --git a/packages/list/.npmignore b/packages/list/.npmignore new file mode 100644 index 000000000..8ea522038 --- /dev/null +++ b/packages/list/.npmignore @@ -0,0 +1,3 @@ +index.js +ListDivider.js +ListItem.js diff --git a/packages/list/ListItem.js b/packages/list/ListItem.js new file mode 100644 index 000000000..f33cae97f --- /dev/null +++ b/packages/list/ListItem.js @@ -0,0 +1,107 @@ +// 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 React, {Component} from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; + +export default class ListItem extends Component { + listItemElement_ = React.createRef(); + + componentDidMount() { + const {init} = this.props; + init && init(); + } + + get listItemElement() { + return this.listItemElement_.current; + } + + get classes() { + const {className} = this.props; + return classnames('mdc-list-item', className); + } + + focus() { + const element = this.listItemElement_.current; + if (element) { + element.focus(); + } + } + + followHref() { + const element = this.listItemElement_.current; + if (element && element.href) { + element.click(); + } + } + + toggleCheckbox() { + // TODO(bonniez): implement + // https://github.com/material-components/material-components-web-react/issues/352 + } + + render() { + const { + /* eslint-disable */ + className, + childrenTabIndex, + init, + id, + /* eslint-enable */ + children, + ...otherProps + } = this.props; + + return ( +
  • + {React.Children.map(children, this.renderChild)} +
  • + ); + } + + renderChild = (child) => { + const props = Object.assign({}, + child.props, + {tabIndex: this.props.childrenTabIndex} + ); + return React.cloneElement(child, props); + } +} + +ListItem.propTypes = { + id: PropTypes.string, + childrenTabIndex: PropTypes.number, + children: PropTypes.node, + className: PropTypes.string, + init: PropTypes.func, +}; + +ListItem.defaultProps = { + id: '', + childrenTabIndex: -1, + className: '', +}; diff --git a/packages/list/ListItemGraphic.js b/packages/list/ListItemGraphic.js new file mode 100644 index 000000000..96cbbf08d --- /dev/null +++ b/packages/list/ListItemGraphic.js @@ -0,0 +1,59 @@ +// 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 React from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; + +const ListItemGraphic = (props) => { + const { + tabIndex, // eslint-disable-line no-unused-vars + graphic, + tabbableOnListItemFocus, + className, + ...otherProps + } = props; + + const graphicProps = { + className: classnames('mdc-list-item__graphic', className), + tabIndex: tabbableOnListItemFocus ? props.tabIndex : -1, + ...otherProps, + }; + + return React.cloneElement(graphic, graphicProps); +}; + +ListItemGraphic.propTypes = { + tabbableOnListItemFocus: PropTypes.bool, + className: PropTypes.string, + tabIndex: PropTypes.number, + graphic: PropTypes.element, +}; + +ListItemGraphic.defaultProps = { + tabbableOnListItemFocus: false, + className: '', + tabIndex: -1, + graphic: {}, +}; + +export default ListItemGraphic; diff --git a/packages/list/ListItemMeta.js b/packages/list/ListItemMeta.js new file mode 100644 index 000000000..41db3b599 --- /dev/null +++ b/packages/list/ListItemMeta.js @@ -0,0 +1,69 @@ +// 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 React from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; + +const ListItemMeta = (props) => { + const { + tabIndex, // eslint-disable-line no-unused-vars + meta, + className, + tabbableOnListItemFocus, + ...otherProps + } = props; + + let metaElement = null; + if (typeof meta === 'string') { + metaElement = {meta}; + } else { + metaElement = meta; + } + + const metaProps = { + className: classnames('mdc-list-item__meta', className, meta.className), + tabIndex: tabbableOnListItemFocus ? props.tabIndex : -1, + ...otherProps, + }; + + return React.cloneElement(metaElement, metaProps); +}; + +ListItemMeta.propTypes = { + tabbableOnListItemFocus: PropTypes.bool, + className: PropTypes.string, + tabIndex: PropTypes.number, + meta: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.element, + ]), +}; + +ListItemMeta.defaultProps = { + tabbableOnListItemFocus: false, + className: '', + tabIndex: -1, + meta: null, +}; + +export default ListItemMeta; diff --git a/packages/list/ListItemText.js b/packages/list/ListItemText.js new file mode 100644 index 000000000..39627effe --- /dev/null +++ b/packages/list/ListItemText.js @@ -0,0 +1,93 @@ +// 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 React from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; + +const ListItemText = (props) => { + const { + primaryText, + secondaryText, + tabbableOnListItemFocus, + tabIndex, + className, + ...otherProps + } = props; + + const renderText = (text, className) => { + if (typeof text === 'string') { + return ( + + {text} + + ); + } + const {className: textClassName, ...otherProps} = text.props; + const props = Object.assign({ + className: classnames(className, textClassName), + }, ...otherProps); + return React.cloneElement(text, props); + }; + + if (!secondaryText) { + return renderText(primaryText, classnames('mdc-list-item__text', className)); + } + + return ( + + {renderText(primaryText, 'mdc-list-item__primary-text')} + {renderText(secondaryText, 'mdc-list-item__secondary-text')} + + ); +}; + +ListItemText.propTypes = { + tabbableOnListItemFocus: PropTypes.bool, + tabIndex: PropTypes.number, + className: PropTypes.string, + primaryText: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.element, + ]), + secondaryText: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.element, + ]), +}; + +ListItemText.defaultProps = { + tabbableOnListItemFocus: false, + tabIndex: -1, + className: '', + primaryText: '', + secondaryText: '', +}; + +export default ListItemText; diff --git a/packages/list/README.md b/packages/list/README.md new file mode 100644 index 000000000..35a842b84 --- /dev/null +++ b/packages/list/README.md @@ -0,0 +1,198 @@ +# React List + +A React version of an [MDC List](https://github.com/material-components/material-components-web/tree/master/packages/mdc-list). + +## Installation + +``` +npm install @material/react-list +``` + +## Usage + +### Styles + +with Sass: +```js +import '@material/react-list/index.scss'; +``` + +with CSS: +```js +import "@material/react-list/dist/list.css"; +``` + +### Javascript Instantiation + +```js +import React, {Component} from 'react'; +import List, {ListItem} from '@material/react-list'; + +class MyApp extends Component { + render() { + return ( + + + + + + + + + + + + ); + } +} +``` + +## Variants + +### Two-Line List + +You can use the `twoLine` Boolean prop for `List` combined with the `secondaryText` prop for `ListItem` to style a list as a double line list. + +```js +class MyApp extends React.Component { + render() { + return ( + + + + + + + + + + + + ); + } +} +``` + +### List item supporting visuals and metadata + +You may add a leading visuals or trailing metadata to a list item using `ListItemGraphic` before or `ListItemMeta` after `ListItemText`. + +```js +import React, {Component} from 'react'; +import List, {ListItem} from '@material/react-list'; + +class MyApp extends Component { + render() { + return ( + + + } /> + + + + ... + + ); + } +} +``` + +### Single Selection + +You can use the `singleSelection` Boolean prop for `List` to allow for selection of list items. You can also set the `selectedIndex` of the list programmatically. + +```js +class MyApp extends React.Component { + state = { + selectedIndex: 1, + }; + + render() { + return ( + + + + + + + + + + + + ); + } +} +``` + +## Props + +### List + +Prop Name | Type | Description +--- | --- | --- +className | String | Classes to be applied to the list element +nonInterative | Boolean | Disables interactivity affordances +dense | Boolean | Styles the density of the list, making it appear more compact +avatarList | Boolean | Configures the leading tiles of each row to display images instead of icons. This will make the graphics of the list items larger +twoLine | Boolean | Styles the list with two lines +singleSelection | Boolean | Allows for single selection of list items +wrapFocus | Boolean | Sets the list to allow the up arrow on the first element to focus the last element of the list and vice versa +selectedIndex | Number | Toggles the selected state of the list item at the given index +aria-orientation | String | Indicates the list orientation +onClick | Function(evt: Event) => void | Callback for handling a click event +onKeyDown | Function(evt: Event) => void | Callback for handling a keydown event +onFocus | Function(evt: Event) => void | Callback for handling a focus event +onBlur | Function(evt: Event) => void | Callback for handling a blur event + +### ListItem + +Prop Name | Type | Description +--- | --- | --- +id | String | Unique identifier for the list item. Defaults to the index +className | String | Classes to be applied to the list item element +childrenTabIndex | Number | Tab index to be applied to all children of the list item +init | Function() => void | Callback executed when list item mounts + +### ListItemText + +Prop Name | Type | Description +--- | --- | --- +className | String | Classes to be applied to the list item text element +tabIndex | Number | Tab index of the list item text +tabbableOnListItemFocus | Boolean | Whether focusing list item will toggle tab index of the list item text. If false, the tab index will always be -1 +primaryText | String | Primary text for the list item +secondaryText | String | Secondary text for the list item + +### ListItemGraphic + +Prop Name | Type | Description +--- | --- | --- +className | String | Classes to be applied to the list item graphic element +tabIndex | Number | Tab index of the list item graphic +tabbableOnListItemFocus | Boolean | Whether focusing list item will toggle tab index of the list item graphic. If false, the tab index will always be -1 +graphic | Element | The graphic element to be displayed in front of list item text + +### ListItemGraphic + +Prop Name | Type | Description +--- | --- | --- +className | String | Classes to be applied to the list item meta element +tabIndex | Number | Tab index of the list item meta +tabbableOnListItemFocus | Boolean | Whether focusing list item will toggle tab index of the list item meta. If false, the tab index will always be -1 +meta | Element or String | The meta element or string to be displayed behind list item text + +## 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-list/README.md#sass-mixins) diff --git a/packages/list/index.js b/packages/list/index.js new file mode 100644 index 000000000..83f05c7d7 --- /dev/null +++ b/packages/list/index.js @@ -0,0 +1,337 @@ +// 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 React, {Component} from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; + +import {MDCListFoundation} from '@material/list/dist/mdc.list'; + +import ListItem from './ListItem'; +import ListItemGraphic from './ListItemGraphic'; +import ListItemText from './ListItemText'; +import ListItemMeta from './ListItemMeta'; + +const ARIA_ORIENTATION = 'aria-orientation'; +const VERTICAL = 'vertical'; + +export default class List extends Component { + listItems_ = {}; + listItemIndices_ = {}; + state = { + listItemAttributes: {}, // maps id to {attr: val} map object + listItemClassList: {}, // maps id to classList set + listItemChildrenTabIndex: {}, // maps id to children's tabIndex + }; + + componentDidMount() { + const {singleSelection, selectedIndex, wrapFocus} = this.props; + this.foundation_ = new MDCListFoundation(this.adapter); + this.foundation_.init(); + this.foundation_.setSingleSelection(singleSelection); + this.foundation_.setWrapFocus(wrapFocus); + this.foundation_.setVerticalOrientation(this.props[ARIA_ORIENTATION] === VERTICAL); + if (selectedIndex) { + this.foundation_.setSelectedIndex(selectedIndex); + } else { + const id = this.getListItemIdFromIndex_(0); + const {listItemAttributes} = this.state; + listItemAttributes[id] = Object.assign({}, listItemAttributes[id], { + tabIndex: 0, + }); + this.setState({listItemAttributes}); + } + } + + componentDidUpdate(prevProps) { + const {singleSelection, selectedIndex, wrapFocus} = this.props; + if (singleSelection !== prevProps.singleSelection) { + this.foundation_.setSingleSelection(singleSelection); + } + if (wrapFocus !== prevProps.wrapFocus) { + this.foundation_.setWrapFocus(wrapFocus); + } + if (selectedIndex !== prevProps.selectedIndex) { + this.foundation_.setSelectedIndex(selectedIndex); + } + if (this.props[ARIA_ORIENTATION] !== prevProps[ARIA_ORIENTATION]) { + this.foundation_.setVerticalOrientation(this.props[ARIA_ORIENTATION] === VERTICAL); + } + } + + componentWillUnmount() { + this.foundation_.destroy(); + } + + initListItem = (id) => { + const {listItemAttributes, listItemChildrenTabIndex, listItemClassList} = this.state; + listItemAttributes[id] = { + 'aria-selected': false, + 'tabIndex': -1, + }; + listItemChildrenTabIndex[id] = -1; + listItemClassList[id] = new Set(); + this.setState({listItemAttributes, listItemChildrenTabIndex, listItemClassList}); + } + + get classes() { + const {className, nonInterative, dense, avatarList, twoLine} = this.props; + return classnames('mdc-list', className, { + 'mdc-list--non-interactive': nonInterative, + 'mdc-list--dense': dense, + 'mdc-list--avatar-list': avatarList, + 'mdc-list--two-line': twoLine, + }); + } + + getListItemClasses_(id, className) { + const {listItemClassList} = this.state; + return listItemClassList[id] ? + classnames(Array.from(listItemClassList[id]), className) : + className; + } + + get adapter() { + return { + getListItemCount: () => Object.keys(this.listItems_).length, + getFocusedElementIndex: () => this.getListItemIndexOfTarget_(document.activeElement), + setAttributeForElementIndex: (index, attr, value) => { + const {listItemAttributes} = this.state; + attr = attr === 'tabindex' ? 'tabIndex' : attr; + const id = this.getListItemIdFromIndex_(index); + listItemAttributes[id][attr] = value; + this.setState({listItemAttributes}); + }, + removeAttributeForElementIndex: (index, attr) => { + const {listItemAttributes} = this.state; + attr = attr === 'tabindex' ? 'tabIndex' : attr; + const id = this.getListItemIdFromIndex_(index); + delete listItemAttributes[id][attr]; + this.setState({listItemAttributes}); + }, + addClassForElementIndex: (index, className) => { + const {listItemClassList} = this.state; + const id = this.getListItemIdFromIndex_(index); + listItemClassList[id].add(className); + this.setState({listItemClassList}); + }, + removeClassForElementIndex: (index, className) => { + const {listItemClassList} = this.state; + const id = this.getListItemIdFromIndex_(index); + listItemClassList[id].delete(className); + this.setState({listItemClassList}); + }, + focusItemAtIndex: (index) => { + const id = this.getListItemIdFromIndex_(index); + const listItem = this.listItems_[id]; + if (listItem) { + listItem.focus(); + } + }, + setTabIndexForListItemChildren: (listItemIndex, tabIndexValue) => { + const {listItemChildrenTabIndex} = this.state; + const id = this.getListItemIdFromIndex_(listItemIndex); + listItemChildrenTabIndex[id]= tabIndexValue; + this.setState({listItemChildrenTabIndex}); + }, + followHref: (index) => { + const id = this.getListItemIdFromIndex_(index); + const listItem = this.listItems_[id]; + if (listItem) { + listItem.followHref(); + } + }, + toggleCheckbox: (index) => { + const id = this.getListItemIdFromIndex_(index); + const listItem = this.listItems_[id]; + if (listItem) { + listItem.toggleCheckbox(); + } + }, + }; + } + + getListItemIdFromIndex_ = (index) => { + for (let id in this.listItemIndices_) { + if (this.listItemIndices_[id] === index) return id; + }; + return ''; + } + + getIndexOfListItemElement_ = (element) => { + let listItemId = ''; + for (let id in this.listItems_) { + if (this.listItems_[id].listItemElement === element) { + listItemId = id; + break; + } + } + return this.listItemIndices_[listItemId]; + } + + getListItemIndexOfTarget_ = (eventTarget) => { + let target = eventTarget; + + // Find the first ancestor that is a list item. + while (this.getIndexOfListItemElement_(target) === undefined) { + if (!target || target === document) { + return -1; + } + target = target.parentElement; + } + + return this.getIndexOfListItemElement_(target); + } + + onKeyDown = (e) => { + this.props.onKeyDown(e); + e.persist(); // Persist the synthetic event to access its `key` + const index = this.getListItemIndexOfTarget_(e.target); + if (index >= 0) { + this.foundation_.handleKeydown(e, true /* isRootListItem is true if index >= 0 */, index); + } + } + + onClick = (e) => { + this.props.onClick(e); + const index = this.getListItemIndexOfTarget_(e.target); + // Toggle the checkbox only if it's not the target of the event, or the checkbox will have 2 change events. + const toggleCheckbox = e.target.type === 'checkbox'; + this.foundation_.handleClick(index, toggleCheckbox); + } + + // Use onFocus as workaround because onFocusIn is not yet supported in React + // https://github.com/facebook/react/issues/6410 + onFocus = (e) => { + this.props.onFocus(e); + const index = this.getListItemIndexOfTarget_(e.target); + this.foundation_.handleFocusIn(e, index); + } + + // Use onBlur as workaround because onFocusOut is not yet supported in React + // https://github.com/facebook/react/issues/6410 + onBlur = (e) => { + this.props.onBlur(e); + const index = this.getListItemIndexOfTarget_(e.target); + this.foundation_.handleFocusOut(e, index); + } + + render() { + const { + /* eslint-disable no-unused-vars */ + className, + onKeyDown, + onClick, + onFocus, + onBlur, + nonInterative, + dense, + avatarList, + twoLine, + singleSelection, + wrapFocus, + selectedIndex, + /* eslint-enable no-unused-vars */ + children, + ...otherProps + } = this.props; + + return ( + + ); + } + + renderListItem = (listItem, index) => { + const { + id, + /* eslint-disable no-unused-vars */ + className, + childrenTabIndex, + /* eslint-enable no-unused-vars */ + children, + ...otherProps + } = listItem.props; + + const idOrIndex = id || index.toString(); + const props = Object.assign({ + id: id, + className: this.getListItemClasses_(idOrIndex), + childrenTabIndex: this.state.listItemChildrenTabIndex[idOrIndex], + init: () => this.initListItem(idOrIndex), + ref: (listItem) => { + this.listItems_[idOrIndex] = listItem; + this.listItemIndices_[idOrIndex] = index; + }, + ...otherProps, + }, + this.state.listItemAttributes[idOrIndex]); + + return React.cloneElement(listItem, props, children); + } +} + +/* eslint-disable quote-props */ + +List.propTypes = { + className: PropTypes.string, + children: PropTypes.node, + nonInterative: PropTypes.bool, + dense: PropTypes.bool, + avatarList: PropTypes.bool, + twoLine: PropTypes.bool, + singleSelection: PropTypes.bool, + wrapFocus: PropTypes.bool, + selectedIndex: PropTypes.number, + 'aria-orientation': PropTypes.string, + onKeyDown: PropTypes.func, + onClick: PropTypes.func, + onFocus: PropTypes.func, + onBlur: PropTypes.func, +}; + +List.defaultProps = { + className: '', + nonInterative: false, + dense: false, + avatarList: false, + twoLine: false, + singleSelection: false, + wrapFocus: true, + 'aria-orientation': VERTICAL, + onKeyDown: () => {}, + onClick: () => {}, + onFocus: () => {}, + onBlur: () => {}, +}; + +/* eslint-enable quote-props */ + +export {ListItem, ListItemGraphic, ListItemText, ListItemMeta}; diff --git a/packages/list/index.scss b/packages/list/index.scss new file mode 100644 index 000000000..231617259 --- /dev/null +++ b/packages/list/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/list/mdc-list"; diff --git a/packages/list/package.json b/packages/list/package.json new file mode 100644 index 000000000..8dd12ad5f --- /dev/null +++ b/packages/list/package.json @@ -0,0 +1,26 @@ +{ + "name": "@material/react-list", + "version": "0.0.0", + "description": "Material Components React List", + "license": "MIT", + "main": "dist/index.js", + "keywords": [ + "mdc web react", + "material components react", + "material design", + "list" + ], + "repository": { + "type": "git", + "url": "https://github.com/material-components/material-components-web-react.git" + }, + "dependencies": { + "@material/list": "^0.40.0", + "classnames": "^2.2.5", + "prop-types": "^15.6.1", + "react": "^16.4.2" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/test/screenshot/golden.json b/test/screenshot/golden.json index 6b832c37f..b50ff4b96 100644 --- a/test/screenshot/golden.json +++ b/test/screenshot/golden.json @@ -3,10 +3,12 @@ "card": "b2fd82763c383be438ff6578083bf9009711c7470333d07eb916ab690fc42d31", "checkbox": "9c61177f0f927e178e7c6687d74cdfa08abc15ea8fc3c381f570b7c7d1f46d2a", "chips": "89fb955abe09193af4e2b0f8cb9f0df06495508d2321fee247fa1a50cfe16a61", - "line-ripple": "56b136db2dc7e09260849447e6bde9b55a837af332a05d9f52506ab1c95e2e57", "fab": "db36f52195c420062d91dd5ebe5432ad87247b3c1146fd547b0a195079bbce2f", "floating-label": "1d4d4f2e57e1769b14fc84985d1e6f53410c49aef41c9cf4fde94f938adefe57", "icon-button": "5ffb1f7fbd06d2c0533f6ba8d4d9ea170cec1a248a61de1cc1bb626cb58ebcd2", + "layout-grid": "fe40f7a34853bc2a1d9a836e812599d4d47b5b26b839a8eaed7f98ea91946790", + "line-ripple": "56b136db2dc7e09260849447e6bde9b55a837af332a05d9f52506ab1c95e2e57", + "list": "2d98c951d42a2e724d57083159aebdc34848bbd3fa2c264c8595ea0982cc1a99", "material-icon": "442b39fb22d2c7a74efb23ca098429b471501ce21df8662327bbf9871fe0bcb0", "menu-surface": "f5face1a24fe166e86e8a3dc35ea85b2d4431469a3d06bf6fc1a30fbdc175aff", "notched-outline": "7770dd381c27608a1f43b6f83da92507fe53963f5e4409bd73184b86275538fe", diff --git a/test/screenshot/list/index.js b/test/screenshot/list/index.js new file mode 100644 index 000000000..cc13ddc0b --- /dev/null +++ b/test/screenshot/list/index.js @@ -0,0 +1,63 @@ +import React from 'react'; +import './index.scss'; +import '../../../packages/list/index.scss'; + +import MaterialIcon from '../../../packages/material-icon'; +import List from '../../../packages/list/index'; +import {ListItem} from '../../../packages/list/index'; +import ListItemGraphic from '../../../packages/list/ListItemGraphic'; +import ListItemText from '../../../packages/list/ListItemText'; +import ListItemMeta from '../../../packages/list/ListItemMeta'; + +class SelectionListTest extends React.Component { + state = { + selectedIndex: 1, // eslint-disable-line react/prop-types + }; + + render() { + const {children, ...otherProps} = this.props; // eslint-disable-line react/prop-types + return ( + + {children} + + ); + } +} + +const renderListItem = (primaryText, secondaryText) => { + return ( + + } /> + + } /> + + ); +}; + +const ListScreenshotTest = () => { + return ( +
    +

    Two-line Selection List

    + + {renderListItem('hello', 'world')} + {renderListItem('hello', 'world')} + {renderListItem('hello', 'world')} + {renderListItem('hello', 'world')} + + +

    One-line List

    + + {renderListItem('hello')} + {renderListItem('hello')} + {renderListItem('hello')} + {renderListItem('hello')} + +
    + ); +}; + +export default ListScreenshotTest; diff --git a/test/screenshot/list/index.scss b/test/screenshot/list/index.scss new file mode 100644 index 000000000..b83615370 --- /dev/null +++ b/test/screenshot/list/index.scss @@ -0,0 +1 @@ +@import "../../../packages/list/index.scss"; diff --git a/test/screenshot/screenshot-test-urls.js b/test/screenshot/screenshot-test-urls.js index d3bd9cd1a..7a61dbd9f 100644 --- a/test/screenshot/screenshot-test-urls.js +++ b/test/screenshot/screenshot-test-urls.js @@ -6,6 +6,7 @@ const urls = [ 'checkbox', 'chips', 'line-ripple', + 'list', 'fab', 'floating-label', 'icon-button', diff --git a/test/unit/list/ListItem.test.js b/test/unit/list/ListItem.test.js new file mode 100644 index 000000000..554738459 --- /dev/null +++ b/test/unit/list/ListItem.test.js @@ -0,0 +1,42 @@ +import React from 'react'; +import {assert} from 'chai'; +import {mount, shallow} from 'enzyme'; +import td from 'testdouble'; +import {ListItem} from '../../../packages/list'; + +suite('ListItem'); + +test('classNames adds classes', () => { + const wrapper = shallow(); + assert.isTrue(wrapper.hasClass('test-class-name')); +}); + +test('#componentDidMount calls #props.init', () => { + const init = td.func(); + mount(); + td.verify(init(), {times: 1}); +}); + +test('#focus focuses the listItemElement_', () => { + const wrapper = mount(); + wrapper.instance().listItemElement_.current.focus = td.func(); + wrapper.instance().focus(); + td.verify(wrapper.instance().listItemElement_.current.focus(), {times: 1}); +}); + +test('#followHref simulates a click on the listItemElement_ if it has href', () => { + const wrapper = mount(); + wrapper.instance().listItemElement_.current.href = true; + wrapper.instance().listItemElement_.current.click = td.func(); + wrapper.instance().followHref(); + td.verify(wrapper.instance().listItemElement_.current.click(), {times: 1}); +}); + +test('passes props.childrenTabIndex to children as props.tabIndex', () => { + const wrapper = mount( + +
    + + ); + assert.equal(wrapper.find('.list-item-child').props().tabIndex, 2); +}); diff --git a/test/unit/list/ListItemGraphic.test.js b/test/unit/list/ListItemGraphic.test.js new file mode 100644 index 000000000..64d88d944 --- /dev/null +++ b/test/unit/list/ListItemGraphic.test.js @@ -0,0 +1,26 @@ +import React from 'react'; +import {assert} from 'chai'; +import {shallow} from 'enzyme'; +import {ListItemGraphic} from '../../../packages/list'; + +suite('ListItemGraphic'); + +test('className adds classes', () => { + const wrapper = shallow(} className='test-class-name' />); + assert.isTrue(wrapper.hasClass('test-class-name')); +}); + +test('has mdc-list-item__graphic class', () => { + const wrapper = shallow(} />); + assert.isTrue(wrapper.hasClass('mdc-list-item__graphic')); +}); + +test('has tabIndex of props.tabIndex if specified and tabbableOnListItemFocus is true', () => { + const wrapper = shallow(} tabIndex={3} tabbableOnListItemFocus/>); + assert.equal(wrapper.find('.mdc-list-item__graphic').props().tabIndex, 3); +}); + +test('has tabIndex of -1 if tabbableOnListItemFocus is false', () => { + const wrapper = shallow(} childrenTabIndex={3}/>); + assert.equal(wrapper.find('.mdc-list-item__graphic').props().tabIndex, -1); +}); diff --git a/test/unit/list/ListItemMeta.test.js b/test/unit/list/ListItemMeta.test.js new file mode 100644 index 000000000..4063b916d --- /dev/null +++ b/test/unit/list/ListItemMeta.test.js @@ -0,0 +1,46 @@ +import React from 'react'; +import {assert} from 'chai'; +import {shallow} from 'enzyme'; +import {ListItemMeta} from '../../../packages/list'; + +suite('ListItemMeta'); + +test('className adds classes if meta is a string', () => { + const wrapper = shallow(); + assert.isTrue(wrapper.hasClass('test-class-name')); +}); + +test('className adds classes if meta is an element', () => { + const wrapper = shallow(} className='test-class-name' />); + assert.isTrue(wrapper.hasClass('test-class-name')); +}); + +test('has mdc-list-item__meta class if meta is a string', () => { + const wrapper = shallow(); + assert.isTrue(wrapper.hasClass('mdc-list-item__meta')); +}); + +test('has mdc-list-item__meta class if meta is an element', () => { + const wrapper = shallow(} />); + assert.isTrue(wrapper.hasClass('mdc-list-item__meta')); +}); + +test('renders span element if meta is a string', () => { + const wrapper = shallow(); + assert.equal(wrapper.find('.mdc-list-item__meta').type(), 'span'); +}); + +test('renders element if meta is an element', () => { + const wrapper = shallow(} />); + assert.equal(wrapper.find('.mdc-list-item__meta').type(), 'button'); +}); + +test('has tabIndex of props.tabIndex if specified and tabbableOnListItemFocus is true', () => { + const wrapper = shallow(} tabIndex={3} tabbableOnListItemFocus/>); + assert.equal(wrapper.find('.mdc-list-item__meta').props().tabIndex, 3); +}); + +test('has tabIndex of -1 if tabbableOnListItemFocus is false', () => { + const wrapper = shallow(} childrenTabIndex={3}/>); + assert.equal(wrapper.find('.mdc-list-item__meta').props().tabIndex, -1); +}); diff --git a/test/unit/list/ListItemText.test.js b/test/unit/list/ListItemText.test.js new file mode 100644 index 000000000..7c054668c --- /dev/null +++ b/test/unit/list/ListItemText.test.js @@ -0,0 +1,45 @@ +import React from 'react'; +import {assert} from 'chai'; +import {shallow} from 'enzyme'; +import {ListItemText} from '../../../packages/list'; + +suite('ListItemText'); + +test('className adds classes', () => { + const wrapper = shallow(); + assert.isTrue(wrapper.hasClass('test-class-name')); +}); + +test('renders primary text if text is string', () => { + const wrapper = shallow(); + assert.isTrue(wrapper.hasClass('mdc-list-item__text')); +}); + +test('renders primary text if text is element', () => { + const wrapper = shallow(Hello} />); + assert.isTrue(wrapper.hasClass('mdc-list-item__text')); +}); + +test('renders primary and secondary text if secondary text provided', () => { + const wrapper = shallow(); + assert.isTrue(wrapper.hasClass('mdc-list-item__text')); + assert.exists(wrapper.find('.mdc-list-item__primary-text')); + assert.exists(wrapper.find('.mdc-list-item__secondary-text')); +}); + +test('renders primary and secondary text if text are elements', () => { + const wrapper = shallow(Hello} secondaryText={World} />); + assert.isTrue(wrapper.hasClass('mdc-list-item__text')); + assert.exists(wrapper.find('.mdc-list-item__primary-text')); + assert.exists(wrapper.find('.mdc-list-item__secondary-text')); +}); + +test('has tabIndex of props.tabIndex if specified and tabbableOnListItemFocus is true', () => { + const wrapper = shallow(); + assert.equal(wrapper.find('.mdc-list-item__text').props().tabIndex, 3); +}); + +test('has tabIndex of -1 if tabbableOnListItemFocus is false', () => { + const wrapper = shallow(); + assert.equal(wrapper.find('.mdc-list-item__text').props().tabIndex, -1); +}); diff --git a/test/unit/list/index.test.js b/test/unit/list/index.test.js new file mode 100644 index 000000000..23a4eda90 --- /dev/null +++ b/test/unit/list/index.test.js @@ -0,0 +1,335 @@ +import React from 'react'; +import {assert} from 'chai'; +import td from 'testdouble'; +import {shallow, mount} from 'enzyme'; +import List from '../../../packages/list'; +import {ListItem} from '../../../packages/list'; + +suite('List'); + +test('creates foundation', () => { + const wrapper = shallow(); + assert.exists(wrapper.instance().foundation_); +}); + +test('#componentWillUnmount destroys foundation', () => { + const wrapper = shallow(); + const foundation = wrapper.instance().foundation_; + foundation.destroy = td.func(); + wrapper.unmount(); + td.verify(foundation.destroy()); +}); + +test('calls foundation.setSingleSelection when props.singleSelection changes from false to true', () => { + const wrapper = mount(); + wrapper.instance().foundation_.setSingleSelection = td.func(); + wrapper.setProps({singleSelection: true}); + td.verify(wrapper.instance().foundation_.setSingleSelection(true), {times: 1}); +}); + +test('calls foundation.setSingleSelection when props.singleSelection changes from true to false', () => { + const wrapper = mount(); + wrapper.instance().foundation_.setSingleSelection = td.func(); + wrapper.setProps({singleSelection: false}); + td.verify(wrapper.instance().foundation_.setSingleSelection(false), {times: 1}); +}); + +test('calls foundation.setWrapFocus when props.wrapFocus changes from false to true', () => { + const wrapper = mount(); + wrapper.instance().foundation_.setWrapFocus = td.func(); + wrapper.setProps({wrapFocus: true}); + td.verify(wrapper.instance().foundation_.setWrapFocus(true), {times: 1}); +}); + +test('calls foundation.setWrapFocus when props.wrapFocus changes from true to false', () => { + const wrapper = mount(); + wrapper.instance().foundation_.setWrapFocus = td.func(); + wrapper.setProps({wrapFocus: false}); + td.verify(wrapper.instance().foundation_.setWrapFocus(false), {times: 1}); +}); + +test('calls foundation.setSelectedIndex when props.selectedIndex changes', () => { + const wrapper = mount(); + wrapper.instance().foundation_.setSelectedIndex = td.func(); + wrapper.setProps({selectedIndex: 1}); + td.verify(wrapper.instance().foundation_.setSelectedIndex(1), {times: 1}); +}); + +test('calls foundation.setVerticalOrientation when \'aria-orientation\' changes from vertical to horizontal', () => { + const wrapper = mount(); + wrapper.instance().foundation_.setVerticalOrientation = td.func(); + wrapper.setProps({'aria-orientation': 'horizontal'}); + td.verify(wrapper.instance().foundation_.setVerticalOrientation(false), {times: 1}); +}); + +test('calls foundation.setVerticalOrientation when \'aria-orientation\' changes from horizontal to vertical', () => { + const wrapper = mount(); + wrapper.instance().foundation_.setVerticalOrientation = td.func(); + wrapper.setProps({'aria-orientation': 'vertical'}); + td.verify(wrapper.instance().foundation_.setVerticalOrientation(true), {times: 1}); +}); + +test('only the first list item in a list is tabbable', () => { + const wrapper = mount( + + + + + + ); + assert.equal(wrapper.state().listItemAttributes['item1'].tabIndex, 0); + assert.equal(wrapper.state().listItemAttributes['item2'].tabIndex, -1); + assert.equal(wrapper.state().listItemAttributes['item3'].tabIndex, -1); +}); + +test('state.listItemChildrenTabIndex is set to -1 for all list items on mount', () => { + const wrapper = mount( + + + + + + ); + assert.equal(wrapper.state().listItemChildrenTabIndex['item1'], -1); + assert.equal(wrapper.state().listItemChildrenTabIndex['item2'], -1); + assert.equal(wrapper.state().listItemChildrenTabIndex['item3'], -1); +}); + +test('list item id falls back to index if id is not provided', () => { + const wrapper = mount( + + + + + + ); + assert.equal(wrapper.state().listItemAttributes['0'].tabIndex, 0); + assert.equal(wrapper.state().listItemAttributes['1'].tabIndex, -1); + assert.equal(wrapper.state().listItemAttributes['2'].tabIndex, -1); + assert.equal(wrapper.state().listItemChildrenTabIndex['0'], -1); + assert.equal(wrapper.state().listItemChildrenTabIndex['1'], -1); + assert.equal(wrapper.state().listItemChildrenTabIndex['2'], -1); +}); + +test('classNames adds classes', () => { + const wrapper = shallow(); + assert.isTrue(wrapper.hasClass('test-class-name')); +}); + +test('#adapter.getListItemCount returns number of list items', () => { + const wrapper = mount( + + + + + + ); + assert.equal(wrapper.instance().adapter.getListItemCount(), 3); +}); + +test('#adapter.setAttributeForElementIndex updates state.listItemAttributes', () => { + const wrapper = mount( + + + + + + ); + wrapper.instance().adapter.setAttributeForElementIndex(1, 'tabindex', 0); + assert.equal(wrapper.state().listItemAttributes['item2']['tabIndex'], 0); +}); + +test('#adapter.removeAttributeForElementIndex updates state.listItemAttributes', () => { + const wrapper = mount( + + + + + + ); + wrapper.instance().adapter.removeAttributeForElementIndex(1, 'tabindex'); + assert.equal(wrapper.state().listItemAttributes['item2']['tabIndex'], null); +}); + +test('#adapter.addClassForElementIndex updates state.listItemClassList', () => { + const wrapper = mount( + + + + + + ); + wrapper.instance().adapter.addClassForElementIndex(1, 'class1'); + assert.isTrue(wrapper.state().listItemClassList['item2'].has('class1')); +}); + +test('#adapter.removeClassForElementIndex updates state.listItemClassList', () => { + const wrapper = mount( + + + + + + ); + wrapper.instance().adapter.removeClassForElementIndex(1, 'class1'); + assert.isFalse(wrapper.state().listItemClassList['item2'].has('class1')); +}); + +test('#adapter.setTabIndexForListItemChildren updates state.listItemChildrenTabIndex', () => { + const wrapper = mount( + + + + + + ); + wrapper.instance().adapter.setTabIndexForListItemChildren(1, 0); + assert.equal(wrapper.state().listItemChildrenTabIndex['item2'], 0); +}); + +test('#adapter.focusItemAtIndex calls focus() on list item', () => { + const item = {focus: td.func()}; + const wrapper = shallow(); + wrapper.instance().listItems_ = {'item': item}; + wrapper.instance().listItemIndices_ = {'item': 1}; + wrapper.instance().adapter.focusItemAtIndex(1); + td.verify(item.focus(), {times: 1}); +}); + +test('#adapter.followHref calls followHref() on list item', () => { + const item = {followHref: td.func()}; + const wrapper = shallow(); + wrapper.instance().listItems_ = {'item': item}; + wrapper.instance().listItemIndices_ = {'item': 1}; + wrapper.instance().adapter.followHref(1); + td.verify(item.followHref(), {times: 1}); +}); + +test('#adapter.toggleCheckbox calls toggleCheckbox() on list item', () => { + const item = {toggleCheckbox: td.func()}; + const wrapper = shallow(); + wrapper.instance().listItems_ = {'item': item}; + wrapper.instance().listItemIndices_ = {'item': 1}; + wrapper.instance().adapter.toggleCheckbox(1); + td.verify(item.toggleCheckbox(), {times: 1}); +}); + +test('on click calls #props.onClick', () => { + const onClick = td.func(); + const wrapper = mount( + + + + + + ); + const item2 = wrapper.instance().listItems_['item2'].listItemElement_.current; + const evt = {target: item2}; + wrapper.simulate('click', evt); + td.verify(onClick(td.matchers.isA(Object)), {times: 1}); +}); + +test('on click calls #foudation.handleClick', () => { + const wrapper = mount( + + + + + + ); + wrapper.instance().foundation_.handleClick = td.func(); + const item2 = wrapper.instance().listItems_['item2'].listItemElement_.current; + const evt = {target: item2}; + wrapper.simulate('click', evt); + td.verify(wrapper.instance().foundation_.handleClick(1, false), {times: 1}); +}); + +test('on keydown calls #props.onKeyDown', () => { + const onKeyDown = td.func(); + const wrapper = mount( + + + + + + ); + const item2 = wrapper.instance().listItems_['item2'].listItemElement_.current; + const evt = {target: item2}; + wrapper.simulate('keydown', evt); + td.verify(onKeyDown(td.matchers.isA(Object)), {times: 1}); +}); + +test('on keydown calls #foudation.handleKeydown', () => { + const wrapper = mount( + + + + + + ); + wrapper.instance().foundation_.handleKeydown = td.func(); + const item2 = wrapper.instance().listItems_['item2'].listItemElement_.current; + const evt = {target: item2}; + wrapper.simulate('keydown', evt); + td.verify(wrapper.instance().foundation_.handleKeydown(td.matchers.isA(Object), true, 1), {times: 1}); +}); + +test('on focus calls #props.onFocus', () => { + const onFocus = td.func(); + const wrapper = mount( + + + + + + ); + const item2 = wrapper.instance().listItems_['item2'].listItemElement_.current; + const evt = {target: item2}; + wrapper.simulate('focus', evt); + td.verify(onFocus(td.matchers.isA(Object)), {times: 1}); +}); + +test('on focus calls #foudation.handleFocusIn', () => { + const wrapper = mount( + + + + + + ); + wrapper.instance().foundation_.handleFocusIn = td.func(); + const item2 = wrapper.instance().listItems_['item2'].listItemElement_.current; + const evt = {target: item2}; + wrapper.simulate('focus', evt); + td.verify(wrapper.instance().foundation_.handleFocusIn(td.matchers.isA(Object), 1), {times: 1}); +}); + +test('on blur calls #props.onBlur', () => { + const onBlur = td.func(); + const wrapper = mount( + + + + + + ); + const item2 = wrapper.instance().listItems_['item2'].listItemElement_.current; + const evt = {target: item2}; + wrapper.simulate('blur', evt); + td.verify(onBlur(td.matchers.isA(Object)), {times: 1}); +}); + +test('on keydown calls #foudation.handleFocusOut', () => { + const wrapper = mount( + + + + + + ); + wrapper.instance().foundation_.handleFocusOut = td.func(); + const item2 = wrapper.instance().listItems_['item2'].listItemElement_.current; + const evt = {target: item2}; + wrapper.simulate('blur', evt); + td.verify(wrapper.instance().foundation_.handleFocusOut(td.matchers.isA(Object), 1), {times: 1}); +});