diff --git a/package.json b/package.json index 9bf0eb861a..10101e2d8e 100644 --- a/package.json +++ b/package.json @@ -248,6 +248,7 @@ "mozilla-version-comparator": "1.0.2", "nano-time": "1.0.0", "normalize.css": "8.0.1", + "photon-colors": "3.3.1", "pino": "5.11.1", "pino-mozlog": "2.0.0", "prop-types": "15.7.1", @@ -342,7 +343,6 @@ "node-sass": "^4.9.1", "object.values": "^1.0.4", "optimize-css-assets-webpack-plugin": "^5.0.1", - "photon-colors": "^3.0.1", "pino-devtools": "^2.1.0", "pino-pretty": "^2.0.1", "piping": "^1.0.0-rc.4", diff --git a/src/amo/components/RatingsByStar/index.js b/src/amo/components/RatingsByStar/index.js index 4161f96499..a5018d15df 100644 --- a/src/amo/components/RatingsByStar/index.js +++ b/src/amo/components/RatingsByStar/index.js @@ -11,7 +11,7 @@ import { reviewListURL } from 'amo/reducers/reviews'; import { withFixedErrorHandler } from 'core/errorHandler'; import translate from 'core/i18n/translate'; import LoadingText from 'ui/components/LoadingText'; -import Icon from 'ui/components/Icon'; +import IconStar from 'ui/components/IconStar'; import type { GroupedRatingsType } from 'amo/api/reviews'; import type { AppState } from 'amo/store'; import type { AddonType } from 'core/types/addons'; @@ -141,7 +141,7 @@ export class RatingsByStarBase extends React.Component { ) : ( createLink(i18n.formatNumber(star)) )} - +
diff --git a/src/amo/components/SearchResult/styles.scss b/src/amo/components/SearchResult/styles.scss index b6f74f3ad6..01483f4721 100644 --- a/src/amo/components/SearchResult/styles.scss +++ b/src/amo/components/SearchResult/styles.scss @@ -146,10 +146,6 @@ $icon-default-size: 32px; .Rating { justify-content: flex-start; margin: 0; - - @include respond-to(large) { - margin-top: 2px; - } } .SearchResult--theme & { diff --git a/src/ui/components/IconStar/index.js b/src/ui/components/IconStar/index.js new file mode 100644 index 0000000000..c149cc55eb --- /dev/null +++ b/src/ui/components/IconStar/index.js @@ -0,0 +1,126 @@ +/* @flow */ +import * as React from 'react'; +import makeClassName from 'classnames'; +import photon from 'photon-colors'; +import uuidv4 from 'uuid/v4'; + +import Icon from 'ui/components/Icon'; +import type { Props as IconProps } from 'ui/components/Icon'; + +export const CLOSED_STYLE = 'closed'; +export const DIM_CLOSED_STYLE = 'dimClosed'; +export const HALF_STYLE = 'half'; +export const OPEN_STYLE = 'open'; + +export const getSvgPath = (starStyle: string) => { + switch (starStyle) { + case CLOSED_STYLE: + case DIM_CLOSED_STYLE: + return 'M154.994575,670.99995 C153.704598,671.000763 152.477615,670.442079 151.630967,669.468394 C150.784319,668.49471 150.401158,667.201652 150.580582,665.923653 L153.046749,648.259919 L141.193762,635.514481 C140.080773,634.318044 139.711733,632.608076 140.232152,631.058811 C140.752571,629.509546 142.078939,628.369589 143.688275,628.088421 L160.214424,625.130961 L168.013827,609.468577 C168.767364,607.955994 170.3113,607 172.000594,607 C173.689888,607 175.233824,607.955994 175.98736,609.468577 L183.790813,625.130961 L200.329111,628.08437 C201.934946,628.371492 203.25546,629.513805 203.771316,631.062053 C204.287172,632.610301 203.915846,634.316807 202.803377,635.51043 L190.954439,648.26397 L193.420606,665.923653 C193.652457,667.578241 192.93975,669.223573 191.574418,670.185702 C190.209085,671.147831 188.420524,671.265104 186.941351,670.489485 L172.002619,662.698806 L157.047688,670.50569 C156.413201,670.833752 155.708782,671.003331 154.994575,670.99995 Z'; + case HALF_STYLE: + return 'M1216.67559,197.013479 C1216.54115,196.628667 1216.19883,196.344304 1215.78203,196.271203 L1211.45804,195.530952 L1209.42135,191.617039 C1209.22458,191.238958 1208.8214,191 1208.38027,191 C1207.93914,191 1207.53597,191.238958 1207.33919,191.617039 L1205.30145,195.530952 L1200.98592,196.269177 C1200.56542,196.339521 1200.21894,196.624766 1200.08323,197.012329 C1199.94751,197.399891 1200.04437,197.827503 1200.33557,198.126387 L1203.43079,201.313214 L1202.78679,205.728392 C1202.72624,206.141968 1202.91235,206.553231 1203.26889,206.793722 C1203.62542,207.034213 1204.09248,207.063526 1204.47874,206.869654 L1208.37974,204.921305 L1212.28181,206.872692 C1212.66807,207.066564 1213.13512,207.037251 1213.49166,206.79676 C1213.84819,206.556269 1214.0343,206.145006 1213.97376,205.73143 L1213.3287,201.313214 L1216.42286,198.1274 C1216.71414,197.828621 1216.81115,197.401068 1216.67559,197.013479 Z'; + case OPEN_STYLE: + default: + return 'M317.994575,670.99995 C316.704598,671.000763 315.477615,670.442079 314.630967,669.468394 C313.784319,668.49471 313.401158,667.201652 313.580582,665.923653 L316.046749,648.259919 L304.193762,635.514481 C303.080773,634.318044 302.711733,632.608076 303.232152,631.058811 C303.752571,629.509546 305.078939,628.369589 306.688275,628.088421 L323.214424,625.130961 L331.013827,609.468577 C331.767364,607.955994 333.3113,607 335.000594,607 C336.689888,607 338.233824,607.955994 338.98736,609.468577 L346.790813,625.130961 L363.329111,628.08437 C364.934946,628.371492 366.25546,629.513805 366.771316,631.062053 C367.287172,632.610301 366.915846,634.316807 365.803377,635.51043 L353.954439,648.26397 L356.420606,665.923653 C356.652457,667.578241 355.93975,669.223573 354.574418,670.185702 C353.209085,671.147831 351.420524,671.265104 349.941351,670.489485 L335.002619,662.698806 L320.047688,670.50569 C319.413201,670.833752 318.708782,671.003331 317.994575,670.99995 Z M314.678006,634.89463 L324.603415,645.569846 L322.578647,660.041143 L335.002619,653.56309 L347.42254,660.045194 L345.397773,645.573897 L355.323182,634.89463 L341.352288,632.39902 L335.002619,619.637378 L328.648899,632.39902 L314.678006,634.89463 Z'; + } +}; + +export type Props = {| + alt?: $PropertyType, + className?: string, + half?: boolean, + selected?: boolean, + readOnly?: boolean, + yellow?: boolean, +|}; + +const IconStar = ({ + className, + half = false, + selected = false, + readOnly = false, + yellow = true, + ...iconProps +}: Props) => { + let color = photon.YELLOW_50; + + if (!yellow) { + color = photon.GREY_50; + } + + let starStyle = selected ? CLOSED_STYLE : OPEN_STYLE; + + if (readOnly) { + if (half) { + starStyle = HALF_STYLE; + } else if (!selected) { + starStyle = DIM_CLOSED_STYLE; + } + } + + let defs; + let gProps = { + fill: color, + fillRule: 'nonzero', + }; + + switch (starStyle) { + case CLOSED_STYLE: + case DIM_CLOSED_STYLE: + gProps = { + ...gProps, + transform: 'translate(-140.000000, -607.000000)', + fillOpacity: starStyle === DIM_CLOSED_STYLE ? 0.25 : 1, + }; + break; + case HALF_STYLE: { + // This id is needed in case there are multiple IconStars on 1 page. + const id = `half${uuidv4()}`; + defs = ( + + + + + + + ); + gProps = { + ...gProps, + fill: `url(#${id})`, + transform: 'scale(3.75) translate(-1200.000000, -191.000000)', + }; + break; + } + case OPEN_STYLE: + default: + gProps = { + ...gProps, + fillOpacity: readOnly ? 0.25 : 1, + transform: 'translate(-303.000000, -607.000000)', + }; + break; + } + + return ( + + + {defs} + + + + + + ); +}; + +export default IconStar; diff --git a/src/ui/components/Rating/img/closed-star-dim-gray.svg b/src/ui/components/Rating/img/closed-star-dim-gray.svg deleted file mode 100644 index 0e37b4b5a9..0000000000 --- a/src/ui/components/Rating/img/closed-star-dim-gray.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - Created with Sketch. - - - - - - - - - diff --git a/src/ui/components/Rating/img/closed-star-dim-yellow.svg b/src/ui/components/Rating/img/closed-star-dim-yellow.svg deleted file mode 100644 index 338353bae1..0000000000 --- a/src/ui/components/Rating/img/closed-star-dim-yellow.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - Created with Sketch. - - - - - - diff --git a/src/ui/components/Rating/img/closed-star-gray.svg b/src/ui/components/Rating/img/closed-star-gray.svg deleted file mode 100644 index 4097065f4a..0000000000 --- a/src/ui/components/Rating/img/closed-star-gray.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Created with Sketch. - - - - - - - diff --git a/src/ui/components/Rating/img/closed-star-yellow.svg b/src/ui/components/Rating/img/closed-star-yellow.svg deleted file mode 100644 index 8879ab3a0d..0000000000 --- a/src/ui/components/Rating/img/closed-star-yellow.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Created with Sketch. - - - - - - - diff --git a/src/ui/components/Rating/img/half-star-gray.svg b/src/ui/components/Rating/img/half-star-gray.svg deleted file mode 100644 index d731313ae1..0000000000 --- a/src/ui/components/Rating/img/half-star-gray.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - Created with Sketch. - - - - - - - - - - - - diff --git a/src/ui/components/Rating/img/half-star-yellow.svg b/src/ui/components/Rating/img/half-star-yellow.svg deleted file mode 100644 index 2495738063..0000000000 --- a/src/ui/components/Rating/img/half-star-yellow.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - Created with Sketch. - - - - - - - - - - - - diff --git a/src/ui/components/Rating/img/open-star-dim-yellow.svg b/src/ui/components/Rating/img/open-star-dim-yellow.svg deleted file mode 100644 index b918bf8556..0000000000 --- a/src/ui/components/Rating/img/open-star-dim-yellow.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - Created with Sketch. - - - - - - - diff --git a/src/ui/components/Rating/index.js b/src/ui/components/Rating/index.js index 910418cf3d..7148ecdc2b 100644 --- a/src/ui/components/Rating/index.js +++ b/src/ui/components/Rating/index.js @@ -8,6 +8,8 @@ import { compose } from 'redux'; import log from 'core/logger'; import translate from 'core/i18n/translate'; import { type I18nType } from 'core/types/i18n'; +import IconStar from 'ui/components/IconStar'; +import type { Props as IconStarProps } from 'ui/components/IconStar'; import './styles.scss'; @@ -121,7 +123,7 @@ export class RatingBase extends React.Component { }; renderRatings(): Array { - const { readOnly } = this.props; + const { readOnly, yellowStars } = this.props; const { hoveringOverStar } = this.state; // Accept falsey values as if they are zeroes. const rating = this.props.rating || 0; @@ -134,11 +136,13 @@ export class RatingBase extends React.Component { const title = this.renderTitle(rating, readOnly, thisRating); + const halfStar = + thisRating - rating > 0.25 && thisRating - rating <= 0.75; + const props = { className: makeClassName('Rating-star', `Rating-rating-${thisRating}`, { 'Rating-selected-star': isSelected, - 'Rating-half-star': - thisRating - rating > 0.25 && thisRating - rating <= 0.75, + 'Rating-half-star': halfStar, }), key: `rating-${thisRating}`, onClick: undefined, @@ -147,7 +151,16 @@ export class RatingBase extends React.Component { }; if (readOnly) { - return
; + return ( +
+ {this.renderStar({ + half: halfStar, + selected: isSelected, + readOnly, + yellow: yellowStars, + })} +
+ ); } if (!this.isLoading()) { @@ -164,15 +177,21 @@ export class RatingBase extends React.Component { type="button" value={thisRating} {...props} - /> - - {title} - + > + + {title} + + {this.renderStar({ selected: isSelected, yellow: true })} + ); }); } + renderStar(props: IconStarProps) { + return ; + } + isLoading() { // When rating is undefined, the rating is still loading. // When rating is null, the rating has been loaded but it's empty. @@ -180,7 +199,7 @@ export class RatingBase extends React.Component { } render() { - const { className, rating, readOnly, styleSize, yellowStars } = this.props; + const { className, rating, readOnly, styleSize } = this.props; if (!styleSize || !RATING_STYLE_SIZES.includes(styleSize)) { throw new Error( oneLine`styleSize=${styleSize || '[empty string]'} is not a valid @@ -201,7 +220,6 @@ export class RatingBase extends React.Component { { 'Rating--editable': !readOnly, 'Rating--loading': this.isLoading(), - 'Rating--yellowStars': yellowStars, }, ); diff --git a/src/ui/components/Rating/styles.scss b/src/ui/components/Rating/styles.scss index bb8fc95422..f147acfd34 100644 --- a/src/ui/components/Rating/styles.scss +++ b/src/ui/components/Rating/styles.scss @@ -1,4 +1,7 @@ @import '~ui/css/styles'; +$icon-large: 64px; +$icon-medium: 19px; +$icon-small: 13px; .Rating { align-content: center; @@ -10,21 +13,48 @@ // The width of small rating stars are controlled by .Rating-star. &.Rating--small { grid-column-gap: 4px; - min-height: 13px; + min-height: $icon-small; width: min-content; + + .IconStar { + display: flex; + height: $icon-medium; + width: $icon-small; + + svg { + width: inherit; + } + } + + &.Rating--editable { + .IconStar { + width: $icon-medium; + + // stylelint-disable max-nesting-depth + @include respond-to(large) { + width: $icon-small; + } + } + } } // The width of large rating stars are controlled by the container. &.Rating--large { grid-column-gap: 6px; + max-width: 300px; min-height: 48px; width: 100%; + + .IconStar { + height: 100%; + width: 100%; + } } @include respond-to(extraExtraLarge) { &.Rating--large { grid-column-gap: 12px; - min-height: 64px; + max-width: none; } } } @@ -53,7 +83,9 @@ } .Rating-star { - background: url('./img/closed-star-dim-gray.svg') center/contain no-repeat; + align-items: center; + display: flex; + justify-content: center; padding: 0; &:not(.focus-visible) { @@ -61,7 +93,7 @@ } .Rating--small & { - min-width: 13px; + min-width: $icon-small; } .Rating--loading & { @@ -71,7 +103,6 @@ .Rating--editable { .Rating-star { - background-image: url('./img/open-star-dim-yellow.svg'); cursor: pointer; } @@ -79,44 +110,12 @@ animation-name: pulseOpaqueRatingStars; // Ensure editable rating stars always render as open stars while // loading, even if some are selected (e.g. while hovering). - background-image: url('./img/open-star-dim-yellow.svg'); cursor: initial; } } -.Rating-selected-star { - background-image: url('./img/closed-star-gray.svg'); -} - -.Rating--editable .Rating-selected-star { - background-image: url('./img/closed-star-yellow.svg'); -} - .Rating-half-star { - background-image: url('./img/half-star-gray.svg'); - [dir='rtl'] & { transform: scaleX(-1); } } - -.Rating--yellowStars { - // When stars are read-only, they are closed. Otherwise, they are open. - .Rating-star { - background-image: url('./img/closed-star-dim-yellow.svg'); - } - - &.Rating--editable .Rating-star { - background-image: url('./img/open-star-dim-yellow.svg'); - } - - .Rating-selected-star, - &.Rating--editable .Rating-selected-star { - background-image: url('./img/closed-star-yellow.svg'); - } - - // This doesn't define an editable half-star because that's not possible. - .Rating-half-star { - background-image: url('./img/half-star-yellow.svg'); - } -} diff --git a/tests/unit/amo/components/TestRatingsByStar.js b/tests/unit/amo/components/TestRatingsByStar.js index 7ebe17441a..263467eb10 100644 --- a/tests/unit/amo/components/TestRatingsByStar.js +++ b/tests/unit/amo/components/TestRatingsByStar.js @@ -16,6 +16,7 @@ import { shallowUntilTarget, } from 'tests/unit/helpers'; import ErrorList from 'ui/components/ErrorList'; +import IconStar from 'ui/components/IconStar'; import LoadingText from 'ui/components/LoadingText'; describe(__filename, () => { @@ -186,6 +187,13 @@ describe(__filename, () => { validateLink(counts.at(4), '1', 'Read all one-star reviews'); }); + it('renders IconStar', () => { + const root = render(); + const star = root.find(IconStar).at(0); + expect(star).toHaveLength(1); + expect(star).toHaveProp('selected'); + }); + it('renders bar value widths based on total ratings', () => { // Set up an add-on with one 5-star rating and one 4-star rating. const grouping = { diff --git a/tests/unit/ui/components/TestIconStar.js b/tests/unit/ui/components/TestIconStar.js new file mode 100644 index 0000000000..a986af7c33 --- /dev/null +++ b/tests/unit/ui/components/TestIconStar.js @@ -0,0 +1,60 @@ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import photon from 'photon-colors'; + +import Icon from 'ui/components/Icon'; +import IconStar, { + getSvgPath, + CLOSED_STYLE, + DIM_CLOSED_STYLE, + HALF_STYLE, + OPEN_STYLE, +} from 'ui/components/IconStar'; + +describe(__filename, () => { + it('sets the default color to YELLOW', () => { + const star = shallow(); + + expect(star.find('g')).toHaveProp('fill', photon.YELLOW_50); + }); + + it('changes the color to GRAY if yellow is false', () => { + const star = shallow(); + + expect(star.find('g')).toHaveProp('fill', photon.GREY_50); + }); + + it('sets the star style to HALF_STYLE if half and readOnly are true', () => { + const star = shallow(); + + expect(star.find('defs')).toHaveLength(1); + expect(star.find('path')).toHaveProp('d', getSvgPath(HALF_STYLE)); + }); + + it("sets the star style to CLOSED_STYLE if the star is selected and it's not readOnly", () => { + const star = shallow(); + + expect(star.find('g')).toHaveProp('fillOpacity', 1); + expect(star.find('path')).toHaveProp('d', getSvgPath(CLOSED_STYLE)); + }); + + it("sets the star style to DIM_CLOSED_STYLE if the star is not selected and it's readOnly", () => { + const star = shallow(); + + expect(star.find('g')).toHaveProp('fillOpacity', 0.25); + expect(star.find('path')).toHaveProp('d', getSvgPath(DIM_CLOSED_STYLE)); + }); + + it("sets the star style to OPEN_STYLE if the star is not selected and it's not readOnly ", () => { + const star = shallow(); + + expect(star.find('g')).toHaveProp('fillOpacity', 1); + expect(star.find('path')).toHaveProp('d', getSvgPath(OPEN_STYLE)); + }); + + it('passes a className to the Icon component', () => { + const star = shallow(); + + expect(star.find(Icon)).toHaveClassName('twinkle'); + }); +}); diff --git a/tests/unit/ui/components/TestRating.js b/tests/unit/ui/components/TestRating.js index c57268f508..b57d7bde77 100644 --- a/tests/unit/ui/components/TestRating.js +++ b/tests/unit/ui/components/TestRating.js @@ -5,6 +5,7 @@ import { fakeI18n, shallowUntilTarget, } from 'tests/unit/helpers'; +import IconStar from 'ui/components/IconStar'; import Rating, { RatingBase } from 'ui/components/Rating'; describe(__filename, () => { @@ -51,9 +52,10 @@ describe(__filename, () => { expect(root).toHaveClassName('Rating--small'); }); - it('can be classified as yellowStars', () => { + it('can be classified as yellow stars', () => { const root = render({ yellowStars: true }); - expect(root).toHaveClassName('Rating--yellowStars'); + const star = root.find(IconStar).at(0); + expect(star).toHaveProp('yellow'); }); it('classifies as yellowStars=false by default', () => { @@ -411,13 +413,34 @@ describe(__filename, () => { ); }); }); + + it("only passes the selected and yellow prop to the IconStar component when it's not readOnly", () => { + const root = render({ readOnly: false }); + + const star = root.find(IconStar).at(0); + + expect(star).toHaveProp('selected'); + expect(star).not.toHaveProp('half'); + }); + + it("passes several props to the IconStar component when it's readOnly", () => { + const root = render({ readOnly: true }); + + const star = root.find(IconStar).at(0); + + expect(star).toHaveProp('selected'); + expect(star).toHaveProp('half'); + expect(star).toHaveProp('yellow'); + expect(star).toHaveProp('readOnly', true); + }); }); describe('rating counts', () => { it('renders the average rating', () => { - expect(render({ rating: 3.5, readOnly: true })).toHaveText( - 'Rated 3.5 out of 5', - ); + const rating = 3.5; + const root = render({ rating, readOnly: true }); + + expect(root).toHaveProp('title', `Rated ${rating} out of 5`); }); it('localizes average rating', () => { @@ -428,9 +451,9 @@ describe(__filename, () => { }); it('renders empty ratings', () => { - expect(renderWithEmptyRating({ readOnly: true })).toHaveText( - 'There are no ratings yet', - ); + const root = renderWithEmptyRating({ readOnly: true }); + + expect(root).toHaveProp('title', 'There are no ratings yet'); }); }); diff --git a/yarn.lock b/yarn.lock index 1138d943c1..5063a85d3b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2342,7 +2342,6 @@ babel-eslint@^10.0.1: "babel-gettext-extractor@github:mozilla/babel-gettext-extractor#babel-7": version "4.0.0" - uid "78ff104e669edb731e979e36fbf4fe261a569924" resolved "https://codeload.github.com/mozilla/babel-gettext-extractor/tar.gz/78ff104e669edb731e979e36fbf4fe261a569924" dependencies: "@babel/core" "^7.0.0" @@ -10338,7 +10337,7 @@ performance-now@^2.1.0: resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= -photon-colors@^3.0.1: +photon-colors@3.3.1: version "3.3.1" resolved "https://registry.yarnpkg.com/photon-colors/-/photon-colors-3.3.1.tgz#74d8322ca459a1bb12f769d2669ef62d20992285" integrity sha512-xE+6KKudiSuoGV1YaVLnYMeKyUpqsVeQUvrUUPAOy4s9YuIdGOVdJ07+YQAZVm7R1cQpi0cm0pcq+DcyTVFVyg==