diff --git a/package-lock.json b/package-lock.json index 652e715ae..ea3b51e9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -244,33 +244,40 @@ "@material/ripple": "^0.39.0" } }, - "@material/switch": { + "@material/tab": { "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@material/switch/-/switch-0.39.0.tgz", - "integrity": "sha512-CV655JTw10wZpb63L4sKtY1HN6miCFO5gxdNxAS2YXbHTvJShJljvhXZZuJ0SxJRAhqgj6fC/wd3KePZ5F0U0A==", + "resolved": "https://registry.npmjs.org/@material/tab/-/tab-0.39.0.tgz", + "integrity": "sha512-I7jc7Me6bDhGm9z/3ZCNo6IdE3ZIX3+5tG3yNNvTKC/+x9o72lROY4b+Zhp0YdQvdiKB4ejh+r4FYQ9PAJbULw==", "dev": true, "requires": { - "@material/animation": "^0.39.0", "@material/base": "^0.39.0", - "@material/elevation": "^0.39.0", "@material/ripple": "^0.39.0", "@material/rtl": "^0.39.0", - "@material/selection-control": "^0.39.0", + "@material/tab-indicator": "^0.39.0", + "@material/theme": "^0.39.0", + "@material/typography": "^0.39.0" + } + }, + "@material/tab-indicator": { + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/@material/tab-indicator/-/tab-indicator-0.39.0.tgz", + "integrity": "sha512-VcmPbhWiisCniiR9sVboiS0oCavIiy/w1EPMD1VOZWIMxQk5nGm6R/PNwZONlFaISLRzmOjtimrafsizsUlphw==", + "dev": true, + "requires": { + "@material/animation": "^0.39.0", + "@material/base": "^0.39.0", "@material/theme": "^0.39.0" } }, - "@material/tab": { + "@material/tab-scroller": { "version": "0.39.3", - "resolved": "https://registry.npmjs.org/@material/tab/-/tab-0.39.3.tgz", - "integrity": "sha512-7MMsO5jSdwlwgEuULBLAdzz8g/jKGkjqCCBgiHMe+E6dNQsQUB9yQyN3EUs+6+nVTdxKGQf5SN7ApFPgtJYMDA==", + "resolved": "https://registry.npmjs.org/@material/tab-scroller/-/tab-scroller-0.39.3.tgz", + "integrity": "sha512-CHWKuzhvR75P6pliLbwBkSIWDbC6YH+DLQEETTmeZXtLXDdvPpNpycfxBRdWK8KuDfhmWOjji+jCx4QYAmILNQ==", "dev": true, "requires": { + "@material/animation": "^0.39.0", "@material/base": "^0.39.0", - "@material/ripple": "^0.39.3", - "@material/rtl": "^0.39.1", - "@material/tab-indicator": "^0.39.3", - "@material/theme": "^0.39.1", - "@material/typography": "^0.39.0" + "@material/tab": "^0.39.3" }, "dependencies": { "@material/ripple": { @@ -290,44 +297,28 @@ "integrity": "sha512-6RcXv6tQladbl+SboEHUykiDAz7dE5DjBuLNV0KD7yEUCcUV41WXZ5e4oUd1nDWnK0vHzFtNM7D6BGMPZ2T3zA==", "dev": true }, - "@material/tab-indicator": { + "@material/tab": { "version": "0.39.3", - "resolved": "https://registry.npmjs.org/@material/tab-indicator/-/tab-indicator-0.39.3.tgz", - "integrity": "sha512-o6RU7PIJrDjbI/Sm//fftNtjl1XUftDKNnZfb27pCzT+IxHRt1prOJmee5y0sZqNAzb9kWb2J8GenIVAmqDzeg==", + "resolved": "https://registry.npmjs.org/@material/tab/-/tab-0.39.3.tgz", + "integrity": "sha512-7MMsO5jSdwlwgEuULBLAdzz8g/jKGkjqCCBgiHMe+E6dNQsQUB9yQyN3EUs+6+nVTdxKGQf5SN7ApFPgtJYMDA==", "dev": true, "requires": { - "@material/animation": "^0.39.0", "@material/base": "^0.39.0", - "@material/theme": "^0.39.1" + "@material/ripple": "^0.39.3", + "@material/rtl": "^0.39.1", + "@material/tab-indicator": "^0.39.3", + "@material/theme": "^0.39.1", + "@material/typography": "^0.39.0" } }, - "@material/theme": { - "version": "0.39.1", - "resolved": "https://registry.npmjs.org/@material/theme/-/theme-0.39.1.tgz", - "integrity": "sha512-c7xjzzHdF4kBl66VJfATBAV9cwD/6TOyVPOWiK5rPxmu9g7uEAe1HmupqJRR1pMFCPX2w85gfvIsxh79EU6YJA==", - "dev": true - } - } - }, - "@material/tab-bar": { - "version": "0.39.3", - "resolved": "https://registry.npmjs.org/@material/tab-bar/-/tab-bar-0.39.3.tgz", - "integrity": "sha512-DF33bY39cnoLg0zK9EjXz4GOVj0pk7J8DSup7aLhHr0UgLOEmVX/o3XmQBG7esdSScQcwwluiEiYbwqqYkWcZQ==", - "dev": true, - "requires": { - "@material/base": "^0.39.0", - "@material/elevation": "^0.39.1", - "@material/tab": "^0.39.3", - "@material/tab-scroller": "^0.39.3" - }, - "dependencies": { - "@material/elevation": { - "version": "0.39.1", - "resolved": "https://registry.npmjs.org/@material/elevation/-/elevation-0.39.1.tgz", - "integrity": "sha512-ir0lqwpkXhJxUQ6KdCTsM2wZze3Oc54knUMbCUnitOWzIRBvlXUO0zlpld5pj3Mb6ApESA6BiaJb4JfEZmX2MA==", + "@material/tab-indicator": { + "version": "0.39.3", + "resolved": "https://registry.npmjs.org/@material/tab-indicator/-/tab-indicator-0.39.3.tgz", + "integrity": "sha512-o6RU7PIJrDjbI/Sm//fftNtjl1XUftDKNnZfb27pCzT+IxHRt1prOJmee5y0sZqNAzb9kWb2J8GenIVAmqDzeg==", "dev": true, "requires": { "@material/animation": "^0.39.0", + "@material/base": "^0.39.0", "@material/theme": "^0.39.1" } }, @@ -339,28 +330,6 @@ } } }, - "@material/tab-indicator": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@material/tab-indicator/-/tab-indicator-0.39.0.tgz", - "integrity": "sha512-VcmPbhWiisCniiR9sVboiS0oCavIiy/w1EPMD1VOZWIMxQk5nGm6R/PNwZONlFaISLRzmOjtimrafsizsUlphw==", - "dev": true, - "requires": { - "@material/animation": "^0.39.0", - "@material/base": "^0.39.0", - "@material/theme": "^0.39.0" - } - }, - "@material/tab-scroller": { - "version": "0.39.3", - "resolved": "https://registry.npmjs.org/@material/tab-scroller/-/tab-scroller-0.39.3.tgz", - "integrity": "sha512-CHWKuzhvR75P6pliLbwBkSIWDbC6YH+DLQEETTmeZXtLXDdvPpNpycfxBRdWK8KuDfhmWOjji+jCx4QYAmILNQ==", - "dev": true, - "requires": { - "@material/animation": "^0.39.0", - "@material/base": "^0.39.0", - "@material/tab": "^0.39.3" - } - }, "@material/textfield": { "version": "0.39.0", "resolved": "https://registry.npmjs.org/@material/textfield/-/textfield-0.39.0.tgz", @@ -15399,9 +15368,9 @@ "dev": true }, "uuid": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.2.1.tgz", - "integrity": "sha512-jZnMwlb9Iku/O3smGWvZhauCf6cvvpKi4BKRiliS3cxnI+Gz9j5MEpTz2UFuXiKPJocb7gnsLHwiS05ige5BEA==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", + "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==", "dev": true }, "uws": { diff --git a/package.json b/package.json index 62e6fd063..abd990fb6 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "allowed": [ "button", "card", + "chips", "fab", "floating-label", "line-ripple", @@ -104,6 +105,7 @@ "resemblejs": "^2.10.3", "sass-loader": "^6.0.7", "testdouble": "^3.6.0", + "uuid": "^3.3.2", "validate-commit-msg": "^2.14.0", "webpack": "^3.11.0", "webpack-dev-server": "^2.11.2" diff --git a/packages/chips/Chip.js b/packages/chips/Chip.js index 5787f4323..ed27a9895 100644 --- a/packages/chips/Chip.js +++ b/packages/chips/Chip.js @@ -27,6 +27,7 @@ import withRipple from '../ripple'; import {MDCChipFoundation} from '@material/chips/dist/mdc.chips'; export class Chip extends Component { + chipElement_ = null; foundation_ = null; state = { classList: new Set(), @@ -35,18 +36,28 @@ export class Chip extends Component { componentDidMount() { this.foundation_ = new MDCChipFoundation(this.adapter); this.foundation_.init(); + this.foundation_.setSelected(this.props.selected); + } + + componentDidUpdate(prevProps) { + if (this.props.selected !== prevProps.selected) { + this.foundation_.setSelected(this.props.selected); + } } componentWillUnmount() { this.foundation_.destroy(); } + init = (el) => { + this.chipElement_ = el; + this.props.initRipple(el); + } + get classes() { const {classList} = this.state; - const {className, selected} = this.props; - return classnames('mdc-chip', Array.from(classList), className, { - 'mdc-chip--selected': selected, - }); + const {className} = this.props; + return classnames('mdc-chip', Array.from(classList), className); } get adapter() { @@ -62,63 +73,126 @@ export class Chip extends Component { this.setState({classList}); }, hasClass: (className) => this.classes.split(' ').includes(className), + eventTargetHasClass: (target, className) => target.classList.contains(className), + getComputedStyleValue: + (propertyName) => window.getComputedStyle(this.chipElement_).getPropertyValue(propertyName), + setStyleProperty: (propertyName, value) => this.chipElement_.style.setProperty(propertyName, value), + notifyRemoval: () => this.props.handleRemove(this.props.id), }; } - handleClick = (e) => { - if (typeof this.props.onClick === 'function') { - this.props.onClick(e); - } - this.props.handleSelect(this.props.id); + onClick = (e) => { + const {onClick, handleSelect, id} = this.props; + onClick(e); + handleSelect(id); } + handleRemoveIconClick = (e) => this.foundation_.handleTrailingIconInteraction(e); + + handleTransitionEnd = (e) => this.foundation_.handleTransitionEnd(e); + + renderLeadingIcon = (leadingIcon) => { + const { + className, + ...otherProps + } = leadingIcon.props; + + const props = { + className: classnames( + className, + 'mdc-chip__icon', + 'mdc-chip__icon--leading', + ), + ...otherProps, + }; + + return React.cloneElement(leadingIcon, props); + }; + + renderRemoveIcon = (removeIcon) => { + const { + className, + ...otherProps + } = removeIcon.props; + + const props = { + className: classnames( + className, + 'mdc-chip__icon', + 'mdc-chip__icon--trailing', + ), + onClick: this.handleRemoveIconClick, + onKeyDown: this.handleRemoveIconClick, + tabIndex: 0, + role: 'button', + ...otherProps, + }; + + return React.cloneElement(removeIcon, props); + }; + render() { const { - className, // eslint-disable-line no-unused-vars - label, - handleSelect, // eslint-disable-line no-unused-vars - onClick, // eslint-disable-line no-unused-vars - chipCheckmark, - computeBoundingRect, // eslint-disable-line no-unused-vars + /* eslint-disable no-unused-vars */ + id, + className, + selected, + handleSelect, + handleRemove, + onClick, + computeBoundingRect, initRipple, - unbounded, // eslint-disable-line no-unused-vars + unbounded, + /* eslint-enable no-unused-vars */ + chipCheckmark, + leadingIcon, + removeIcon, + label, ...otherProps } = this.props; return (
+ {leadingIcon ? this.renderLeadingIcon(leadingIcon) : null} {chipCheckmark}
{label}
+ {removeIcon ? this.renderRemoveIcon(removeIcon) : null}
); } } Chip.propTypes = { - id: PropTypes.number, + id: PropTypes.string.isRequired, label: PropTypes.string, className: PropTypes.string, selected: PropTypes.bool, handleSelect: PropTypes.func, + handleRemove: PropTypes.func, onClick: PropTypes.func, - // The following props are handled by withRipple and do not require defaults. initRipple: PropTypes.func, unbounded: PropTypes.bool, chipCheckmark: PropTypes.node, + leadingIcon: PropTypes.element, + removeIcon: PropTypes.element, computeBoundingRect: PropTypes.func, }; Chip.defaultProps = { - id: -1, label: '', className: '', selected: false, + onClick: () => {}, + initRipple: () => {}, handleSelect: () => {}, + handleRemove: () => {}, }; export default withRipple(Chip); diff --git a/packages/chips/ChipSet.js b/packages/chips/ChipSet.js index 6d419b33f..7ddfb6fc5 100644 --- a/packages/chips/ChipSet.js +++ b/packages/chips/ChipSet.js @@ -23,24 +23,86 @@ import React, {Component} from 'react'; import classnames from 'classnames'; import PropTypes from 'prop-types'; +import {MDCChipSetFoundation} from '@material/chips'; import ChipCheckmark from './ChipCheckmark'; -import Chip from './Chip'; export default class ChipSet extends Component { checkmarkWidth_ = 0; + foundation_ = null; + state = { + selectedChipIds: new Set(this.props.selectedChipIds), + }; + + componentDidMount() { + this.foundation_ = new MDCChipSetFoundation(this.adapter); + this.foundation_.init(); + this.updateChipSelection(); + } + + componentDidUpdate(prevProps) { + if (this.props.selectedChipIds !== prevProps.selectedChipIds) { + this.updateChipSelection(); + } + } + + componentWillUnmount() { + this.foundation_.destroy(); + } get classes() { - const {className} = this.props; - return classnames('mdc-chip-set', className); + const {className, filter, choice, input} = this.props; + return classnames('mdc-chip-set', className, { + 'mdc-chip-set--filter': filter, + 'mdc-chip-set--choice': choice, + 'mdc-chip-set--input': input, + }); } get adapter() { return { hasClass: (className) => this.classes.split(' ').includes(className), + setSelected: (chipId, selected) => { + const {selectedChipIds} = this.state; + if (selected) { + selectedChipIds.add(chipId); + } else { + selectedChipIds.delete(chipId); + } + this.setState({selectedChipIds}); + }, }; } + updateChipSelection() { + React.Children.forEach(this.props.children, (child) => { + const {id} = child.props; + if (this.props.selectedChipIds.indexOf(id) > -1) { + this.foundation_.select(id); + } else { + this.foundation_.deselect(id); + } + }); + } + + handleSelect = (chipId) => { + const {handleSelect, choice, filter} = this.props; + if (filter || choice) { + this.foundation_.toggleSelect(chipId); + } + handleSelect(this.foundation_.getSelectedChipIds()); + } + + handleRemove = (chipId) => { + const {input, handleRemove} = this.props; + if (input) { + // this should be calling foundation_.handleChipRemoval, but we would + // need to pass evt.detail.chipId + this.foundation_.deselect(chipId); + } + handleRemove(chipId); + } + setCheckmarkWidth = (checkmark) => { if (!!this.checkmarkWidth_) { return; @@ -49,18 +111,24 @@ export default class ChipSet extends Component { } computeBoundingRect = (chipElement) => { - const height = chipElement.getBoundingClientRect().height; - const width = chipElement.getBoundingClientRect().width + this.checkmarkWidth_; + const {height, width: chipWidth} = chipElement.getBoundingClientRect(); + const width = chipWidth + this.checkmarkWidth_; return {height, width}; } renderChip = (chip) => { - return ( - : null} - computeBoundingRect={this.props.filter ? this.computeBoundingRect : null} - {...chip.props} /> - ); + const {filter} = this.props; + const {selectedChipIds} = this.state; + + const props = Object.assign({ + selected: selectedChipIds.has(chip.props.id), + handleSelect: this.handleSelect, + handleRemove: this.handleRemove, + chipCheckmark: filter ? : null, + computeBoundingRect: filter ? this.computeBoundingRect : null, + }, ...chip.props); + + return React.cloneElement(chip, props); } render() { @@ -74,12 +142,22 @@ export default class ChipSet extends Component { ChipSet.propTypes = { className: PropTypes.string, + selectedChipIds: PropTypes.array, + handleSelect: PropTypes.func, + handleRemove: PropTypes.func, + choice: PropTypes.bool, filter: PropTypes.bool, + input: PropTypes.bool, children: PropTypes.node, }; ChipSet.defaultProps = { className: '', + selectedChipIds: [], + handleSelect: () => {}, + handleRemove: () => {}, + choice: false, filter: false, + input: false, children: null, }; diff --git a/packages/chips/README.md b/packages/chips/README.md index d8912d64e..c96200327 100644 --- a/packages/chips/README.md +++ b/packages/chips/README.md @@ -32,8 +32,8 @@ class MyApp extends Component { render() { return ( - - + + ); } @@ -51,27 +51,19 @@ There are two types of chips that allow for selection: [choice chips](https://ma ```js class MyChoiceChips extends React.Component { state = { - selectedChipId: -1, + selectedChipIds: ['chip2'], }; - isSelected = (id) => { - return this.state.selectedChipId === id; - } - - handleSelect = (id) => { - if (this.isSelected(id)) { - this.setState({selectedChipId: -1}); - } else { - this.setState({selectedChipId: id}); - } - } - render() { return ( - - - - + this.setState(selectedChipIds)} + > + + + ); } @@ -85,30 +77,76 @@ Filter chips include a leading checkmark to indicate selection. To define a set ```js class MyFilterChips extends React.Component { state = { - selectedChipIds: new Set(), + selectedChipIds: ['chip1', 'chip2'], }; - isSelected = (id) => { - return this.state.selectedChipIds.has(id); + render() { + return ( + this.setState(selectedChipIds)} + > + + + + + ); } +} +``` + +### Input chips + +Input chips are a variant of chips which enable user input by converting text into chips. Chips may be dynamically added and removed from the chip set. To define a set of chips as input chips, add the `input` prop to the `ChipSet`. To remove a chip, pass a callback to the `Chip` through the `handleRemove` prop. - handleSelect = (id) => { - const selectedChipIds = new Set(this.state.selectedChipIds); - if (this.isSelected(id)) { - selectedChipIds.delete(id); - } else { - selectedChipIds.add(id); +> _NOTE_: We recommend you store an array of chip labels and their respective IDs in the `state` to manage adding/removing chips. Do _NOT_ use the chip's index as its ID or key, because its index may change due to the addition/removal of other chips. + +```js +class MyInputChips extends React.Component { + state = { + chips: [ + {label: 'Jane Smith', id: 'janesmith'}, + {label: 'John Doe', id: 'johndoe'} + ], + }; + + handleKeyDown = (e) => { + // If you have a more complex input, you may want to store the value in the state. + const label = e.target.value; + if (!!label && e.key === 'Enter') { + const id = label.replace(/\s/g,''); + // Create a new chips array to ensure that a re-render occurs. + // See: https://reactjs.org/docs/state-and-lifecycle.html#do-not-modify-state-directly + const chips = [...this.state.chips]; + chips.push({label, id}); + this.setState({chips}); + e.target.value = ''; } - this.setState({selectedChipIds}); + } + + // Removes the chip element from the page + handleRemove = (id) => { + const chips = [...this.state.chips]; + const index = chips.findIndex(chip => chip.id === id); + chips.splice(index, 1); + this.setState({chips}); } render() { return ( - - - - - +
+ + + {this.state.chips.map((chip) => + } + /> + )} + +
); } } @@ -121,7 +159,12 @@ class MyFilterChips extends React.Component { Prop Name | Type | Description --- | --- | --- className | String | Classes to be applied to the chip set element +selectedChipIds | Array | Array of ids of chips that are selected +handleSelect | Function(id: string) => void | Callback for selecting the chip with the given id +handleRemove | Function(id: string) => void | Callback for removing the chip with the given id +choice | Boolean | Indicates that the chips in the set are choice chips, which allow single selection from a set of options filter | Boolean | Indicates that the chips in the set are filter chips, which allow multiple selection from a set of options +input | Boolean | Indicates that the chips in the set are input chips, where chips can be added or removed ### Chip @@ -129,10 +172,13 @@ filter | Boolean | Indicates that the chips in the set are filter chips, which a Prop Name | Type | Description --- | --- | --- className | String | Classes to be applied to the chip element -id | Number | Unique identifier for the chip +id | Number | Required. Unique identifier for the chip label | String | Text to be shown on the chip +leadingIcon | Element | An icon element that appears as the leading icon. +removeIcon | Element | An icon element that appears as the remove icon. Clicking on it should remove the chip. selected | Boolean | Indicates whether the chip is selected -handleSelect | Function(id: number) => void | Callback to call when the chip with the given id is selected +handleSelect | Function(id: string) => void | Callback for selecting the chip with the given id +handleRemove | Function(id: string) => void | Callback for removing the chip with the given id ## Sass Mixins diff --git a/packages/chips/package_skip.json b/packages/chips/package.json similarity index 100% rename from packages/chips/package_skip.json rename to packages/chips/package.json diff --git a/scripts/screenshot-directory-reader.js b/scripts/screenshot-directory-reader.js index 2c9684288..a66aa73a1 100644 --- a/scripts/screenshot-directory-reader.js +++ b/scripts/screenshot-directory-reader.js @@ -1,10 +1,8 @@ const {lstatSync, readdirSync} = require('fs'); const {basename, join, resolve} = require('path'); -const denyList = [ - 'chips', - 'images' -]; +const denyList = ['images']; + const isDirectory = source => lstatSync(source).isDirectory(); const getDirectories = source => readdirSync(source).map(name => join(source, name)).filter(isDirectory); diff --git a/test/screenshot/chips/index.js b/test/screenshot/chips/index.js index 3eda2c87b..80f8d79e5 100644 --- a/test/screenshot/chips/index.js +++ b/test/screenshot/chips/index.js @@ -2,73 +2,143 @@ import React from 'react'; import './index.scss'; import '../../../packages/chips/index.scss'; -import {Chip, ChipSet} from '../../../packages/chips'; +import MaterialIcon from '../../../packages/material-icon'; +import {Chip, ChipSet} from '../../../packages/chips/index'; +import uuidv1 from 'uuid/v1'; -class ShirtSizes extends React.Component { +class ChoiceChipsTest extends React.Component { state = { - selectedChipId: 0, + selectedChipIds: this.props.selectedChipIds, // eslint-disable-line react/prop-types }; - isSelected = (id) => { - return this.state.selectedChipId === id; + render() { + const {children} = this.props; // eslint-disable-line react/prop-types + return ( +
+ this.setState({selectedChipIds})} + > + {children} + +
+ ); } +} - handleSelect = (id) => { - if (this.isSelected(id)) { - this.setState({selectedChipId: -1}); - } else { - this.setState({selectedChipId: id}); - } - } +class FilterChipsTest extends React.Component { + state = { + selectedChipIds: this.props.selectedChipIds, // eslint-disable-line react/prop-types + }; render() { + const {children} = this.props; // eslint-disable-line react/prop-types return ( - - - - - +
+ this.setState({selectedChipIds})} + > + {children} + + +
); } } -class ShoppingFilters extends React.Component { +class InputChipsTest extends React.Component { state = { - selectedChipIds: new Set([0, 1]), + chips: this.props.labels.map((label) => { // eslint-disable-line react/prop-types + return {label: label, id: uuidv1()}; + }), }; - isSelected = (id) => { - return this.state.selectedChipIds.has(id); + addChip(label) { + // Create a new chips array to ensure that a re-render occurs. + // See: https://reactjs.org/docs/state-and-lifecycle.html#do-not-modify-state-directly + const chips = [...this.state.chips]; + chips.push({label, id: uuidv1()}); + this.setState({chips}); } - handleSelect = (id) => { - const selectedChipIds = new Set(this.state.selectedChipIds); - if (this.isSelected(id)) { - selectedChipIds.delete(id); - } else { - selectedChipIds.add(id); + handleKeyDown = (e) => { + if (e.key === 'Enter' && e.target.value) { + this.addChip(e.target.value); + e.target.value = ''; } - this.setState({selectedChipIds}); } render() { return ( - - - - - +
+ + { + const {chips} = this.state; + for (let i = 0; i < chips.length; i ++) { + if (chips[i].id === chipId) { + chips.splice(index, 1); + this.setState(chips); + return; + } + } + }}> + {this.state.chips.map((chip) => + } + removeIcon={} + /> + )} + +
); } } +const seasons = ['Winter', 'Summer', 'Spring', 'Autumn']; +const sizes = ['Small', 'Medium', 'Large']; +const clothes = ['Tops', 'Bottoms', 'Shoes']; +const contacts = ['Jane Smith', 'John Doe']; + +const renderChips = (list) => { + return list.map((label, index) => ( + + )); +}; + const ChipsScreenshotTest = () => { return (
- Choice chips - - Filter chips - + Default Chips + + {renderChips(seasons)} + + + Choice Chips + + {renderChips(sizes)} + + + Filter Chips + + {renderChips(clothes)} + + + Input chips +
); }; diff --git a/test/screenshot/golden.json b/test/screenshot/golden.json index 031492cd9..a15c1d1c5 100644 --- a/test/screenshot/golden.json +++ b/test/screenshot/golden.json @@ -2,6 +2,7 @@ "button": "a0cf6b2a5d09400657303bb2a7b220dbe33642a8f44fd7571cdfcf5517fb3586", "card": "b2fd82763c383be438ff6578083bf9009711c7470333d07eb916ab690fc42d31", + "chips": "9cdf56c10f4badd9555433e84a52d52028f286f42392f4902251dcb8eb748e33", "line-ripple": "56b136db2dc7e09260849447e6bde9b55a837af332a05d9f52506ab1c95e2e57", "fab": "db36f52195c420062d91dd5ebe5432ad87247b3c1146fd547b0a195079bbce2f", "floating-label": "1d4d4f2e57e1769b14fc84985d1e6f53410c49aef41c9cf4fde94f938adefe57", @@ -22,4 +23,4 @@ "top-app-bar/standard": "90534d59d40f8c050aae022e94b0afa022a00d1e00c19e3f92dcc1908efcd831", "top-app-bar/standardNoActionItems": "9102ece0efc0a040e0dc2b097c66d2ee5ecc9c91759aeb16ea5aa2baabe7307a", "top-app-bar/standardWithNavigationIconElement": "91b10df9c4faf85ba63c84b1cf82a1c1143a3d8c4ba7ed8af477fabdc65ec25e" -} +} diff --git a/test/screenshot/screenshot-test-urls.js b/test/screenshot/screenshot-test-urls.js index db76d1fbc..f1b595b7e 100644 --- a/test/screenshot/screenshot-test-urls.js +++ b/test/screenshot/screenshot-test-urls.js @@ -3,7 +3,7 @@ import topAppBarVariants from './top-app-bar/variants'; const urls = [ 'button', 'card', - // 'chips', + 'chips', 'line-ripple', 'fab', 'floating-label', diff --git a/test/unit/chips/Chip.test.js b/test/unit/chips/Chip.test.js index e19a1ce84..c736b0923 100644 --- a/test/unit/chips/Chip.test.js +++ b/test/unit/chips/Chip.test.js @@ -1,54 +1,190 @@ -// import React from 'react'; -// import {assert} from 'chai'; -// import {mount} from 'enzyme'; -// import td from 'testdouble'; -// import {Chip, ChipCheckmark} from '../../../packages/chips/index'; -// -// suite('Chip'); -// -// test('creates foundation', () => { -// const wrapper = mount(Hello world); -// assert.exists(wrapper.instance().foundation_); -// }); -// -// test('classNames adds classes', () => { -// const wrapper = mount(); -// assert.isTrue(wrapper.hasClass('test-class-name')); -// }); -// -// test('renders chip', () => { -// const wrapper = mount(Hello world); -// assert.exists(wrapper.find('.mdc-chip')); -// }); -// -// test('#adapter.addClass adds class to state.classList', () => { -// const wrapper = mount(Jane Doe); -// wrapper.instance().foundation_.adapter_.addClass('test-class-name'); -// assert.isTrue(wrapper.state().classList.has('test-class-name')); -// }); -// -// test('#adapter.removeClass removes class from state.classList', () => { -// const wrapper = mount(Jane Doe); -// const classList = new Set(); -// classList.add('test-class-name'); -// wrapper.setState({classList}); -// wrapper.instance().foundation_.adapter_.removeClass('test-class-name'); -// assert.isFalse(wrapper.state().classList.has('test-class-name')); -// }); -// -// test('on click calls #props.handleSelect', () => { -// const handleSelect = td.func(); -// const wrapper = mount(Jane Doe); -// wrapper.simulate('click'); -// td.verify(handleSelect(123)); -// }); -// -// test('renders chip checkmark if it exists', () => { -// const wrapper = mount(}>Jane Doe); -// assert.exists(wrapper.find('.mdc-chip__checkmark')); -// }); -// -// test('adds mndc-chip--selected class if selected prop is true', () => { -// const wrapper = mount(Jane Doe); -// assert.exists(wrapper.hasClass('.mdc-chip--selected')); -// }); +import React from 'react'; +import {assert} from 'chai'; +import {mount, shallow} from 'enzyme'; +import td from 'testdouble'; +import {Chip} from '../../../packages/chips/Chip'; +import ChipCheckmark from '../../../packages/chips/ChipCheckmark'; + +suite('Chip'); + +test('creates foundation', () => { + const wrapper = mount(); + assert.exists(wrapper.instance().foundation_); +}); + +test('calls setSelected if props.selected is true (#foundation.setSelected)', () => { + const wrapper = mount(Hello world); + assert.isTrue(wrapper.state().classList.has('mdc-chip--selected')); +}); + +test('classNames adds classes', () => { + const wrapper = shallow(); + assert.isTrue(wrapper.hasClass('test-class-name')); +}); + +test('classNames adds classes from state.classList', () => { + const wrapper = shallow(); + wrapper.setState({classList: new Set(['test-class-name'])}); + assert.isTrue(wrapper.hasClass('test-class-name')); +}); + +test('#adapter.addClass adds class to state.classList', () => { + const wrapper = shallow(); + wrapper.instance().adapter.addClass('test-class-name'); + assert.isTrue(wrapper.state().classList.has('test-class-name')); +}); + +test('#adapter.removeClass removes class from state.classList', () => { + const wrapper = shallow(); + const classList = new Set(['test-class-name']); + wrapper.setState({classList}); + wrapper.instance().adapter.removeClass('test-class-name'); + assert.isFalse(wrapper.state().classList.has('test-class-name')); +}); + +test('#adapter.hasClass returns true if component contains class', () => { + const wrapper = shallow(); + assert.isTrue(wrapper.instance().adapter.hasClass('test-class-name')); +}); + +test('#adapter.eventTargetHasClass returns true if element contains class', () => { + const wrapper = shallow(); + const target = document.createElement('div'); + target.classList.add('test-class-name'); + assert.isTrue(wrapper.instance().adapter.eventTargetHasClass(target, 'test-class-name')); +}); + +test('#adapter.getComputedStyleValue should get styles from chip element', () => { + const div = document.createElement('div'); + // needs to be attached to real DOM to get width + // https://github.com/airbnb/enzyme/issues/1525 + document.body.append(div); + const options = {attachTo: div}; + + const wrapper = mount(, options); + const width = '10px'; + const chipElement = wrapper.find('.mdc-chip').getDOMNode(); + chipElement.style.setProperty('width', width); + assert.equal(wrapper.instance().adapter.getComputedStyleValue('width'), width); + div.remove(); +}); + +test('#adapter.setStyleProperty should add styles to chip', () => { + const wrapper = mount(); + const width = '10px'; + wrapper.instance().adapter.setStyleProperty('width', width); + const chipElement = wrapper.find('.mdc-chip').getDOMNode(); + assert.equal(chipElement.style.width, width); +}); + +test('on click calls #props.onClick', () => { + const onClick = td.func(); + const wrapper = shallow(); + const evt = {}; + wrapper.simulate('click', evt); + td.verify(onClick(evt), {times: 1}); +}); + +test('on click calls #props.handleSelect', () => { + const handleSelect = td.func(); + const wrapper = shallow(); + wrapper.simulate('click'); + td.verify(handleSelect('123'), {times: 1}); +}); + +test('renders leading icon', () => { + const leadingIcon = ; + const wrapper = shallow(); + assert.equal(wrapper.children().first().type(), 'i'); +}); + +test('renders leading icon with class name', () => { + const leadingIcon = ; + const wrapper = shallow(); + assert.isTrue(wrapper.children().first().hasClass('leading-icon')); +}); + +test('renders leading icon with base class names', () => { + const leadingIcon = ; + const wrapper = shallow(); + assert.isTrue(wrapper.children().first().hasClass('mdc-chip__icon')); + assert.isTrue(wrapper.children().first().hasClass('mdc-chip__icon--leading')); +}); + +test('renders remove icon', () => { + const removeIcon = ; + const wrapper = shallow(); + assert.equal(wrapper.children().last().type(), 'i'); +}); + +test('renders remove icon with class name', () => { + const removeIcon = ; + const wrapper = shallow(); + assert.isTrue(wrapper.children().last().hasClass('remove-icon')); +}); + +test('renders remove icon with base class names', () => { + const removeIcon = ; + const wrapper = shallow(); + assert.isTrue(wrapper.children().last().hasClass('mdc-chip__icon')); + assert.isTrue(wrapper.children().last().hasClass('mdc-chip__icon--trailing')); +}); + +test('remove icon click calls #foundation.handleTrailingIconInteraction', () => { + const removeIcon = ; + const wrapper = shallow(); + wrapper.instance().foundation_.handleTrailingIconInteraction = td.func(); + const evt = {}; + wrapper.children().last().simulate('click', evt); + td.verify(wrapper.instance().foundation_.handleTrailingIconInteraction(evt), {times: 1}); +}); + +test('remove icon keydown calls #foundation.handleTrailingIconInteraction', () => { + const removeIcon = ; + const wrapper = shallow(); + wrapper.instance().foundation_.handleTrailingIconInteraction = td.func(); + const evt = {}; + wrapper.children().last().simulate('keydown', evt); + td.verify(wrapper.instance().foundation_.handleTrailingIconInteraction(evt), {times: 1}); +}); + +test('calls #foundation.handleTransitionEnd on transitionend event', () => { + const wrapper = shallow(); + wrapper.instance().foundation_.handleTransitionEnd = td.func(); + const evt = {}; + wrapper.simulate('transitionend', evt); + td.verify(wrapper.instance().foundation_.handleTransitionEnd(evt), {times: 1}); +}); + +test('renders chip checkmark if it exists', () => { + const wrapper = mount(} />); + assert.equal(wrapper.find('.mdc-chip__checkmark').length, 1); +}); + +test('renders custom chip checkmark', () => { + const wrapper = shallow(} />); + assert.equal(wrapper.find('.meow-class').length, 1); +}); + +test('adds mdc-chip--selected class if selected prop is true', () => { + const wrapper = shallow(); + assert.exists(wrapper.hasClass('mdc-chip--selected')); +}); + +test('renders chip', () => { + const wrapper = shallow(); + assert.isTrue(wrapper.find('.mdc-chip').length === 1); +}); + +test('renders label text', () => { + const wrapper = shallow(); + assert.equal(wrapper.find('.mdc-chip__text').text(), 'Hello Jane'); +}); + +test('#componentWillUnmount destroys foundation', () => { + const wrapper = shallow(); + const foundation = wrapper.instance().foundation_; + foundation.destroy = td.func(); + wrapper.unmount(); + td.verify(foundation.destroy()); +}); diff --git a/test/unit/chips/ChipCheckmark.test.js b/test/unit/chips/ChipCheckmark.test.js new file mode 100644 index 000000000..962553ba9 --- /dev/null +++ b/test/unit/chips/ChipCheckmark.test.js @@ -0,0 +1,11 @@ +import React from 'react'; +import {assert} from 'chai'; +import {mount} from 'enzyme'; +import ChipCheckmark from '../../../packages/chips/ChipCheckmark'; + +suite('ChipCheckmark'); + +test('renders with element and sets ref', () => { + const wrapper = mount(); + assert.equal(wrapper.instance().width, 0); +}); diff --git a/test/unit/chips/ChipSet.test.js b/test/unit/chips/ChipSet.test.js index 174705c2e..e3da31883 100644 --- a/test/unit/chips/ChipSet.test.js +++ b/test/unit/chips/ChipSet.test.js @@ -1,39 +1,260 @@ -// import React from 'react'; -// import {assert} from 'chai'; -// import {shallow} from 'enzyme'; -// import {Chip, ChipSet} from '../../../packages/chips/index'; -// -// suite('ChipSet'); - -// test('className prop adds classes', () => { -// const wrapper = shallow(); -// assert.isTrue(wrapper.hasClass('test-class-name')); -// assert.isTrue(wrapper.hasClass('mdc-chip-set')); -// }); -// -// test('renders chip set and chip', () => { -// const wrapper = shallow( -// -// -// -// -// ); -// assert.exists(wrapper.find('.mdc-chip-set')); -// assert.lengthOf(wrapper.find(Chip), 2); -// }); -// -// test('selected filter chip renders checkmark', () => { -// const wrapper = shallow( -// -// -// -// -// ); -// assert.exists(wrapper.find('.mdc-chip__checkmark')); -// }); -// -// test('#adapter.hasClass returns true if class was added in className prop', () => { -// const wrapper = shallow(); -// assert.isTrue(wrapper.instance().adapter.hasClass('test-class-name')); -// assert.isTrue(wrapper.instance().adapter.hasClass('mdc-chip-set')); -// }); +import React from 'react'; +import {assert} from 'chai'; +import td from 'testdouble'; +import {shallow, mount} from 'enzyme'; +import ChipSet from '../../../packages/chips/ChipSet'; +import {Chip} from '../../../packages/chips/index'; +import ChipCheckmark from '../../../packages/chips/ChipCheckmark'; + +suite('ChipSet'); + +test('creates foundation', () => { + const wrapper = mount(); + assert.exists(wrapper.instance().foundation_); +}); + +test('calls #foundation.select when the selectedChipIds change', () => { + const wrapper = shallow(
); + wrapper.instance().updateChipSelection = td.func(); + const selectedChipIds = ['1']; + wrapper.setProps({selectedChipIds}); + td.verify(wrapper.instance().updateChipSelection(), {times: 1}); +}); + +test('filter classname is added if is filter variant', () => { + const wrapper = shallow(
); + wrapper.hasClass('mdc-chip-set--filter'); +}); + +test('choice classname is added if is choice variant', () => { + const wrapper = shallow(
); + wrapper.hasClass('mdc-chip-set--choice'); +}); + +test('input classname is added if is input variant', () => { + const wrapper = shallow(
); + wrapper.hasClass('mdc-chip-set--input'); +}); + +test('#adapter.hasClass returns true if component contains class', () => { + const wrapper = shallow(
); + assert.isTrue(wrapper.instance().adapter.hasClass('test-class-name')); +}); + +test('#adapter.hasClass returns false if component does not contains class', () => { + const wrapper = shallow(
); + assert.isFalse(wrapper.instance().adapter.hasClass('test-class-name')); +}); + +test('#adapter.setSelected adds selectedChipId to state', () => { + const wrapper = shallow(
); + wrapper.instance().adapter.setSelected('1', true); + assert.isTrue(wrapper.state().selectedChipIds.has('1')); +}); + +test('#adapter.setSelected removes selectedChipId from state', () => { + const wrapper = shallow(
); + wrapper.setState({selectedChipIds: new Set(['1'])}); + wrapper.instance().adapter.setSelected('1', false); + assert.isFalse(wrapper.state().selectedChipIds.has('1')); +}); + +test('#adapter.setSelected removes selectedChipId from state', () => { + const wrapper = shallow(
); + wrapper.setState({selectedChipIdss: new Set(['1'])}); + wrapper.instance().adapter.setSelected('1', false); + assert.isFalse(wrapper.state().selectedChipIds.has('1')); +}); + +test('#foundation.select is called when the selectedChipIds change', () => { + const wrapper = shallow(
); + wrapper.instance().foundation_.select = td.func(); + const selectedChipIds = ['1']; + wrapper.setProps({selectedChipIds}); + td.verify(wrapper.instance().foundation_.select('1'), {times: 1}); +}); + +test('#foundation.deselect is called when the selectedChipIds change', () => { + const wrapper = shallow(
); + const selectedChipIds = ['1']; + wrapper.setProps({selectedChipIds}); + wrapper.instance().foundation_.deselect = td.func(); + wrapper.setProps({selectedChipIds: []}); + + td.verify(wrapper.instance().foundation_.deselect('1'), {times: 1}); +}); + +test('#handleSelect calls props.handleSelect', () => { + const handleSelect = td.func(); + const wrapper = shallow(); + wrapper.instance().handleSelect(); + td.verify(handleSelect([]), {times: 1}); +}); + +test('#handleSelect calls props.handleSelect with selectedChipIds', () => { + const handleSelect = td.func(); + const wrapper = shallow( +
+ ); + wrapper.instance().handleSelect(); + td.verify(handleSelect(['1']), {times: 1}); +}); + +test('#handleSelect calls foundation_.toggleSelect with chipId and is choice variant', () => { + const handleSelect = td.func(); + const wrapper = shallow( +
+ ); + wrapper.instance().foundation_.toggleSelect = td.func(); + wrapper.instance().handleSelect('1'); + td.verify(wrapper.instance().foundation_.toggleSelect('1'), {times: 1}); +}); + +test('#handleSelect calls foundation_.toggleSelect with chipId and is filter variant', () => { + const handleSelect = td.func(); + const wrapper = shallow( +
+ ); + wrapper.instance().foundation_.toggleSelect = td.func(); + wrapper.instance().handleSelect('1'); + td.verify(wrapper.instance().foundation_.toggleSelect('1'), {times: 1}); +}); + +test('#handleSelect does not call foundation_.toggleSelect with chipId if is neither filter nor choice', () => { + const handleSelect = td.func(); + const wrapper = shallow( +
+ ); + wrapper.instance().foundation_.toggleSelect = td.func(); + wrapper.instance().handleSelect('1'); + td.verify(wrapper.instance().foundation_.toggleSelect('1'), {times: 0}); +}); + +test('#handleRemove calls foundation_.deselect with chipId and is input variant', () => { + const handleRemove = td.func(); + const wrapper = shallow( +
+ ); + wrapper.instance().foundation_.deselect = td.func(); + wrapper.instance().handleRemove('1'); + td.verify(wrapper.instance().foundation_.deselect('1'), {times: 1}); +}); + +test('#handleRemove does not call foundation_.deselect with chipId if is not input variant', () => { + const handleRemove = td.func(); + const wrapper = shallow( +
+ ); + wrapper.instance().foundation_.deselect = td.func(); + wrapper.instance().handleRemove('1'); + td.verify(wrapper.instance().foundation_.deselect('1'), {times: 0}); +}); + +test('#handleRemove calls props.handleRemove with chipId', () => { + const handleRemove = td.func(); + const wrapper = shallow(); + wrapper.instance().handleRemove('1'); + td.verify(handleRemove('1'), {times: 1}); +}); + +test('#setCheckmarkWidth sets checkmark width', () => { + const wrapper = shallow(); + wrapper.instance().setCheckmarkWidth({width: 20}); + assert.equal(wrapper.instance().checkmarkWidth_, 20); +}); + +test('#setCheckmarkWidth does not set checkmark width if checkmark width is already set', () => { + const wrapper = shallow(); + wrapper.instance().checkmarkWidth_ = 20; + wrapper.instance().setCheckmarkWidth({width: 40}); + assert.equal(wrapper.instance().checkmarkWidth_, 20); +}); + +test('#computeBoundingRect returns width and height', () => { + const wrapper = shallow(); + const chipWidth = 20; + const chipHeight = 50; + const chipElement = { + getBoundingClientRect: () => ({width: chipWidth, height: chipHeight}), + }; + const {height, width} = wrapper.instance().computeBoundingRect(chipElement); + assert.equal(height, chipHeight); + assert.equal(width, chipWidth); +}); + +test('#computeBoundingRect returns width and height', () => { + const wrapper = shallow(); + const chipWidth = 20; + const chipHeight = 50; + wrapper.instance().checkmarkWidth_ = 20; + const chipElement = { + getBoundingClientRect: () => ({width: chipWidth, height: chipHeight}), + }; + const {height, width} = wrapper.instance().computeBoundingRect(chipElement); + assert.equal(height, chipHeight); + assert.equal(width, chipWidth+20); +}); + +test('chip is rendered with selected prop as false', () => { + const wrapper = mount(); + const chip = wrapper.children().props().children[0]; + assert.isFalse(chip.props.selected); +}); + +test('chip is rendered with selected prop as true', () => { + const wrapper = mount(); + wrapper.setState({selectedChipIds: new Set(['1'])}); + const chip = wrapper.children().props().children[0]; + assert.isTrue(chip.props.selected); +}); + +test('chip is rendered with handleSelect method', () => { + const handleSelect = td.func(); + const wrapper = mount(); + const chip = wrapper.children().props().children[0]; + chip.props.handleSelect('1'); + td.verify(handleSelect([]), {times: 1}); +}); + +test('chip is rendered with handleRemove method', () => { + const handleRemove = td.func(); + const wrapper = mount(); + const chip = wrapper.children().props().children[0]; + chip.props.handleRemove('1'); + td.verify(handleRemove('1'), {times: 1}); +}); + +test('chip is rendered ChipCheckmark if is filter variants', () => { + const wrapper = mount(); + const chip = wrapper.children().props().children[0]; + assert.equal(chip.props.chipCheckmark.type, ChipCheckmark); +}); + +test('chip is rendered ChipCheckmark if is not filter variants', () => { + const wrapper = mount(); + const chip = wrapper.children().props().children[0]; + assert.equal(chip.props.chipCheckmark, null); +}); + +test('chip is rendered computeBoundingRect method prop if is filter variant', () => { + const wrapper = mount(); + const chip = wrapper.children().props().children[0]; + const chipElement = {getBoundingClientRect: td.func()}; + td.when(chipElement.getBoundingClientRect()).thenReturn({height: 1, width: 1}); + chip.props.computeBoundingRect(chipElement); + td.verify(chipElement.getBoundingClientRect(), {times: 1}); +}); + +test('chip is rendered computeBoundingRect method prop if is not filter variant', () => { + const wrapper = mount(); + const chip = wrapper.children().props().children[0]; + assert.equal(chip.props.computeBoundingRect, null); +}); + +test('#componentWillUnmount destroys foundation', () => { + const wrapper = shallow(); + const foundation = wrapper.instance().foundation_; + foundation.destroy = td.func(); + wrapper.unmount(); + td.verify(foundation.destroy()); +});