From 224531c76f751fc29e8dd838187e147d37fa146c Mon Sep 17 00:00:00 2001 From: Levi Thomason Date: Sun, 7 Aug 2016 19:09:08 -0700 Subject: [PATCH 1/3] feat(Rating): add Rating component --- src/index.js | 5 +- src/modules/Rating/Rating.js | 156 +++++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 src/modules/Rating/Rating.js diff --git a/src/index.js b/src/index.js index 8c7aca6602..a42ca2ca83 100644 --- a/src/index.js +++ b/src/index.js @@ -62,7 +62,7 @@ export Rail from './elements/Rail/Rail' // ---------------------------------------- export Accordion from './modules/Accordion/Accordion' export Checkbox from './modules/Checkbox/Checkbox' -export Progress from './modules/Progress/Progress' +export Dropdown from './modules/Dropdown/Dropdown' import _Modal from './modules/Modal/Modal' export { _Modal as Modal } @@ -70,7 +70,8 @@ export const ModalContent = deprecateComponent('ModalContent', 'Use "Modal.Conte export const ModalFooter = deprecateComponent('ModalFooter', 'Use "Modal.Footer" instead.', _Modal.Footer) export const ModalHeader = deprecateComponent('ModalHeader', 'Use "Modal.Header" instead.', _Modal.Header) -export Dropdown from './modules/Dropdown/Dropdown' +export Progress from './modules/Progress/Progress' +export Rating from './modules/Rating/Rating' // ---------------------------------------- // Views diff --git a/src/modules/Rating/Rating.js b/src/modules/Rating/Rating.js new file mode 100644 index 0000000000..e26beeee05 --- /dev/null +++ b/src/modules/Rating/Rating.js @@ -0,0 +1,156 @@ +import _ from 'lodash' +import cx from 'classnames' +import React, { PropTypes } from 'react' + +import AutoControlledComponent from '../../utils/AutoControlledComponent' +import META from '../../utils/Meta' +import { getUnhandledProps } from '../../utils/propUtils' +import * as sui from '../../utils/semanticUtils' + +const _meta = { + name: 'Rating', + type: META.type.module, + props: { + clearable: ['auto'], + icon: ['star', 'heart'], + size: _.without(sui.sizes, 'medium', 'big'), + }, +} + +class Rating extends AutoControlledComponent { + static propTypes = { + /** Additional className. */ + className: PropTypes.string, + + /** + * You can clear the rating by clicking on the current start rating. + * By default a rating will be only clearable if there is 1 icon. + * Setting to `true`/`false` will allow or disallow a user to clear their rating. + */ + clearable: PropTypes.oneOfType([ + PropTypes.oneOf(_meta.props.clearable), + PropTypes.bool, + ]), + + /** A rating can use a set of star or heart icons. */ + icon: PropTypes.oneOf(_meta.props.icon), + + /** The total number of icons. */ + maxRating: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + ]), + + /** The current number of active icons. */ + rating: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + ]), + + /** The initial rating value. */ + defaultRating: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + ]), + + /** A progress bar can vary in size. */ + size: PropTypes.oneOf(_meta.props.size), + + /** You can disable or enable interactive rating. Makes a read-only rating. */ + disabled: PropTypes.bool, + + /** Called with (event, { rating, maxRating }) after user selects a new rating. */ + onRate: PropTypes.func, + } + + static defaultProps = { + clearable: 'auto', + maxRating: 1, + } + + static _meta = _meta + + static autoControlledProps = [ + 'rating', + ] + + handleMouseLeave = (...args) => { + _.invoke(this.props, 'onMouseLeave', ...args) + + if (this.props.disabled) return + + this.setState({ selectedIndex: -1, isSelecting: false }) + } + + handleIconMouseEnter = (index) => { + if (this.props.disabled) return + + this.setState({ selectedIndex: index, isSelecting: true }) + } + + handleIconClick = (e, index) => { + const { clearable, disabled, maxRating, onRate } = this.props + const { rating } = this.state + if (disabled) return + + // default newRating is the clicked icon + // allow toggling a binary rating + // allow clearing ratings + let newRating = index + 1 + if (clearable === 'auto' && maxRating === 1) { + newRating = +!rating + } else if (clearable === true && newRating === rating) { + newRating = 0 + } + + // set rating + this.trySetState({ rating: newRating }, { isSelecting: false }) + if (onRate) onRate(e, { rating: newRating, maxRating }) + } + + renderIcons = () => { + const { maxRating } = this.props + const { rating, selectedIndex, isSelecting } = this.state + + return _.times(maxRating, (i) => { + const classes = cx( + selectedIndex >= i && isSelecting && 'selected', + rating >= i + 1 && 'active', + 'icon' + ) + return ( + this.handleIconClick(e, i)} + onMouseEnter={() => this.handleIconMouseEnter(i)} + /> + ) + }) + } + + render() { + const { className, disabled, icon, size } = this.props + const { selectedIndex, isSelecting } = this.state + + const classes = cx( + 'ui', + size, + icon, + disabled && 'disabled', + isSelecting && !disabled && selectedIndex >= 0 && 'selected', + 'rating', + className, + ) + + const rest = getUnhandledProps(Rating, this.props) + + return ( +
+ {this.renderIcons()} +
+ ) + } +} + +export default Rating From 12ecf3971028185c22a66924f4434375393f8897 Mon Sep 17 00:00:00 2001 From: Levi Thomason Date: Sun, 7 Aug 2016 19:09:28 -0700 Subject: [PATCH 2/3] test(Rating): test the Rating component --- test/specs/modules/Rating/Rating-test.js | 233 +++++++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 test/specs/modules/Rating/Rating-test.js diff --git a/test/specs/modules/Rating/Rating-test.js b/test/specs/modules/Rating/Rating-test.js new file mode 100644 index 0000000000..d6dba68e10 --- /dev/null +++ b/test/specs/modules/Rating/Rating-test.js @@ -0,0 +1,233 @@ +import _ from 'lodash' +import React from 'react' + +import Rating from 'src/modules/Rating/Rating' +import * as common from 'test/specs/commonTests' +import sandbox from 'test/utils/Sandbox-util' + +describe('Rating', () => { + common.isConformant(Rating) + common.hasUIClassName(Rating) + + common.propValueOnlyToClassName(Rating, 'size') + common.propValueOnlyToClassName(Rating, 'icon') + common.propKeyOnlyToClassName(Rating, 'disabled') + + describe('clicking on icons', () => { + it('makes icons active up to and including the clicked icon', () => { + const wrapper = mount() + const icons = wrapper.find('.icon') + + icons.at(1).simulate('click') + + icons.at(0).should.have.className('active') + icons.at(1).should.have.className('active') + icons.at(2).should.not.have.className('active') + }) + + it('removes the "selected" class', () => { + const wrapper = mount() + const icons = wrapper.find('.icon') + + icons + .last() + .simulate('mouseEnter') + .simulate('click') + + icons.every('.icon.selected') + .should.equal(false, 'Some icon did not remove its "selected" class') + + wrapper.should.not.have.className('selected') + }) + }) + + describe('hovering on icons', () => { + it('adds the "selected" className to the Rating', () => { + const wrapper = shallow() + + wrapper + .find('.icon') + .first() + .simulate('mouseEnter') + + wrapper.should.have.className('selected') + }) + it('selects icons up to and including the hovered icon', () => { + const wrapper = mount() + const icons = wrapper.find('.icon') + + icons.at(1).simulate('mouseEnter') + + icons.at(0).should.have.className('selected') + icons.at(1).should.have.className('selected') + icons.at(2).should.not.have.className('selected') + }) + it('unselects icons on mouse leave', () => { + const wrapper = mount() + const icons = wrapper.find('.icon') + + icons.last().simulate('mouseEnter') + wrapper.simulate('mouseLeave') + + icons.every('.icon.selected') + .should.equal(false, 'Some icon did not remove its "selected" class') + }) + }) + + describe('clearable', () => { + it('prevents clearing by default with multiple icons', () => { + const icons = mount() + .find('.icon') + + icons.find('.active').last().simulate('click') + + icons.every('.icon.active') + .should.equal(true, 'Some icon did not retain its "active" class') + }) + it('allows toggling when set to "auto" with a single icon', () => { + const icon = mount() + .find('.icon') + .at(0) + + icon + .simulate('click') + .should.have.className('active') + + icon + .simulate('click') + .should.not.have.className('active') + }) + it('allows clearing when true with a single icon', () => { + mount() + .find('.icon') + .at(0) + .simulate('click') + .should.not.have.className('active') + }) + it('allows clearing when true with multiple icons', () => { + const icons = mount() + .find('.icon') + + icons.find('.active').last().simulate('click') + + icons.every('.icon.active') + .should.equal(false, 'Some icon did not remove its "active" class') + }) + it('prevents clearing when false with a single icon', () => { + mount() + .find('.icon') + .at(0) + .simulate('click') + .should.have.className('active') + }) + it('prevents clearing when false with multiple icons', () => { + const icons = mount() + .find('.icon') + + icons.find('.active').last().simulate('click') + + icons.every('.icon.active') + .should.equal(true, 'Some icon did not retain its "active" class') + }) + }) + + describe('disabled', () => { + it('prevents the rating from being toggled', () => { + mount() + .find('.icon') + .at(0) + .simulate('click') + .should.have.className('active') + + mount() + .find('.icon') + .at(0) + .simulate('click') + .should.not.have.className('active') + }) + it('prevents the rating from being cleared', () => { + const wrapper = mount() + const icons = wrapper.find('.icon') + + icons.find('.active').last().simulate('click') + + icons.every('.icon.active') + .should.equal(true, 'Some icon lost its "active" class') + }) + it('prevents icons from becoming selected on mouse enter', () => { + const wrapper = mount() + const icons = wrapper.find('.icon') + + icons.last().simulate('mouseEnter') + + icons.every('.icon.selected') + .should.equal(false, 'Some icon became "selected"') + }) + it('prevents icons from becoming unselected on mouse leave', () => { + const wrapper = mount() + const icons = wrapper.find('.icon') + + icons.last().simulate('mouseEnter') + icons.every('.icon.selected') + .should.equal(true, 'Not every icon was selected on mouseEnter') + + wrapper.setProps({ disabled: true }) + wrapper.simulate('mouseLeave') + + icons.every('.icon.selected') + .should.equal(true, 'Some icon lost its "selected" class') + }) + it('prevents icons from becoming active on click', () => { + const wrapper = mount() + const icons = wrapper.find('.icon') + + icons.last().simulate('click') + + icons.every('.icon.active') + .should.equal(false, 'Some icon became "active"') + }) + }) + + describe('maxRating', () => { + it('controls how many icons are displayed', () => { + _.times(10, (i) => { + const maxRating = i + 1 + shallow() + .should.have.exactly(maxRating).descendants('.icon') + }) + }) + }) + + describe('onRate', () => { + it('is called with (event, { rating, maxRating } on icon click', () => { + const spy = sandbox.spy() + const event = { fake: 'event data' } + + shallow() + .find('.icon') + .last() + .simulate('click', event) + + spy.should.have.been.calledOnce() + spy.should.have.been.calledWithMatch(event, { rating: 3, maxRating: 3 }) + }) + }) + + describe('rating', () => { + it('controls how many icons are active', () => { + const wrapper = shallow() + + // rating 0 + wrapper.should.not.have.descendants('.icon.active') + + // rating 1 - 10 + _.times(10, (i) => { + const rating = i + 1 + + wrapper + .setProps({ rating }) + .should.have.exactly(rating).descendants('.icon.active') + }) + }) + }) +}) From fc71b753a6161191e2136a127b6ddd2ee2690616 Mon Sep 17 00:00:00 2001 From: Levi Thomason Date: Sun, 7 Aug 2016 19:09:47 -0700 Subject: [PATCH 3/3] docs(Rating): add Rating docs --- README.md | 2 +- .../Rating/Types/RatingClearableExample.js | 8 +++ .../Rating/Types/RatingControlledExample.js | 21 +++++++ .../Rating/Types/RatingDisabledExample.js | 8 +++ .../Rating/Types/RatingHeartExample.js | 8 +++ .../Rating/Types/RatingOnRateExample.js | 17 ++++++ .../Rating/Types/RatingRatingExample.js | 8 +++ .../modules/Rating/Types/RatingStarExample.js | 8 +++ .../Rating/Variations/RatingSizeExample.js | 35 ++++++++++++ docs/app/Examples/modules/Rating/index.js | 57 +++++++++++++++++++ 10 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 docs/app/Examples/modules/Rating/Types/RatingClearableExample.js create mode 100644 docs/app/Examples/modules/Rating/Types/RatingControlledExample.js create mode 100644 docs/app/Examples/modules/Rating/Types/RatingDisabledExample.js create mode 100644 docs/app/Examples/modules/Rating/Types/RatingHeartExample.js create mode 100644 docs/app/Examples/modules/Rating/Types/RatingOnRateExample.js create mode 100644 docs/app/Examples/modules/Rating/Types/RatingRatingExample.js create mode 100644 docs/app/Examples/modules/Rating/Types/RatingStarExample.js create mode 100644 docs/app/Examples/modules/Rating/Variations/RatingSizeExample.js create mode 100644 docs/app/Examples/modules/Rating/index.js diff --git a/README.md b/README.md index 39a6e3451e..5d51ba2ffd 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ Be sure to check out the above migrations before embarking on a new component. | x Image | | | _ Nag | | | x Input | | | _ Popup | | | x Label | | | x Progress | | -| x List | | | _ Rating | | +| x List | | | x Rating | | | x Loader | | | _ Search | | | x Rail | | | _ Shape | | | _ Reveal | | | _ Sidebar | | diff --git a/docs/app/Examples/modules/Rating/Types/RatingClearableExample.js b/docs/app/Examples/modules/Rating/Types/RatingClearableExample.js new file mode 100644 index 0000000000..cd14922d06 --- /dev/null +++ b/docs/app/Examples/modules/Rating/Types/RatingClearableExample.js @@ -0,0 +1,8 @@ +import React from 'react' +import { Rating } from 'stardust' + +const RatingClearableExample = () => ( + +) + +export default RatingClearableExample diff --git a/docs/app/Examples/modules/Rating/Types/RatingControlledExample.js b/docs/app/Examples/modules/Rating/Types/RatingControlledExample.js new file mode 100644 index 0000000000..b6dd1dfd2d --- /dev/null +++ b/docs/app/Examples/modules/Rating/Types/RatingControlledExample.js @@ -0,0 +1,21 @@ +import React, { Component } from 'react' +import { Rating } from 'stardust' + +export default class RatingControlledExample extends Component { + state = { rating: 0 } + + handleChange = (e) => this.setState({ rating: e.target.value }) + + render() { + const { rating } = this.state + + return ( +
+
Rating: {rating}
+ +
+ +
+ ) + } +} diff --git a/docs/app/Examples/modules/Rating/Types/RatingDisabledExample.js b/docs/app/Examples/modules/Rating/Types/RatingDisabledExample.js new file mode 100644 index 0000000000..79fc4cc174 --- /dev/null +++ b/docs/app/Examples/modules/Rating/Types/RatingDisabledExample.js @@ -0,0 +1,8 @@ +import React from 'react' +import { Rating } from 'stardust' + +const RatingDisabledExample = () => ( + +) + +export default RatingDisabledExample diff --git a/docs/app/Examples/modules/Rating/Types/RatingHeartExample.js b/docs/app/Examples/modules/Rating/Types/RatingHeartExample.js new file mode 100644 index 0000000000..8c2935819c --- /dev/null +++ b/docs/app/Examples/modules/Rating/Types/RatingHeartExample.js @@ -0,0 +1,8 @@ +import React from 'react' +import { Rating } from 'stardust' + +const RatingHeartExample = () => ( + +) + +export default RatingHeartExample diff --git a/docs/app/Examples/modules/Rating/Types/RatingOnRateExample.js b/docs/app/Examples/modules/Rating/Types/RatingOnRateExample.js new file mode 100644 index 0000000000..b7fb911d50 --- /dev/null +++ b/docs/app/Examples/modules/Rating/Types/RatingOnRateExample.js @@ -0,0 +1,17 @@ +import React, { Component } from 'react' +import { Rating } from 'stardust' + +export default class RatingOnRateExample extends Component { + state = {} + + handleRate = (e, { rating, maxRating }) => this.setState({ rating, maxRating }) + + render() { + return ( +
+ +
{JSON.stringify(this.state, null, 2)}
+
+ ) + } +} diff --git a/docs/app/Examples/modules/Rating/Types/RatingRatingExample.js b/docs/app/Examples/modules/Rating/Types/RatingRatingExample.js new file mode 100644 index 0000000000..907b683754 --- /dev/null +++ b/docs/app/Examples/modules/Rating/Types/RatingRatingExample.js @@ -0,0 +1,8 @@ +import React from 'react' +import { Rating } from 'stardust' + +const RatingExample = () => ( + +) + +export default RatingExample diff --git a/docs/app/Examples/modules/Rating/Types/RatingStarExample.js b/docs/app/Examples/modules/Rating/Types/RatingStarExample.js new file mode 100644 index 0000000000..ce10585bdd --- /dev/null +++ b/docs/app/Examples/modules/Rating/Types/RatingStarExample.js @@ -0,0 +1,8 @@ +import React from 'react' +import { Rating } from 'stardust' + +const RatingStarExample = () => ( + +) + +export default RatingStarExample diff --git a/docs/app/Examples/modules/Rating/Variations/RatingSizeExample.js b/docs/app/Examples/modules/Rating/Variations/RatingSizeExample.js new file mode 100644 index 0000000000..24d47ecea2 --- /dev/null +++ b/docs/app/Examples/modules/Rating/Variations/RatingSizeExample.js @@ -0,0 +1,35 @@ +import React from 'react' +import { Rating } from 'stardust' + +const RatingSizeExample = () => ( +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+) +export default RatingSizeExample diff --git a/docs/app/Examples/modules/Rating/index.js b/docs/app/Examples/modules/Rating/index.js new file mode 100644 index 0000000000..6daf928cdc --- /dev/null +++ b/docs/app/Examples/modules/Rating/index.js @@ -0,0 +1,57 @@ +import React, { Component } from 'react' +import ComponentExample from 'docs/app/Components/ComponentDoc/ComponentExample' +import ExampleSection from 'docs/app/Components/ComponentDoc/ExampleSection' + +export default class RatingExamples extends Component { + render() { + return ( +
+ + + + + + + + + + + + + +
+ ) + } +}