Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(rating): a new component rating indicator #543

Merged
merged 6 commits into from
Jun 25, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ export type { ProgressProps } from './progress'
export { default as Radio } from './radio'
export type { RadioProps, RadioGroupProps, RadioDescriptionProps } from './radio'

export { default as Rating } from './rating'
export type { RatingProps } from './rating'

export { default as Select } from './select'
export type { SelectProps, SelectOptionProps } from './select'

Expand Down
362 changes: 362 additions & 0 deletions components/rating/__tests__/__snapshots__/index.test.tsx.snap

Large diffs are not rendered by default.

108 changes: 108 additions & 0 deletions components/rating/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import React, { useState } from 'react'
import { Rating } from 'components'
import { mount } from 'enzyme'
import { nativeEvent } from 'tests/utils'

describe('Rating', () => {
it('should render correctly', () => {
const wrapper = mount(<Rating />)
expect(wrapper.html()).toMatchSnapshot()
expect(() => wrapper.unmount()).not.toThrow()
})

it('should work with different types', () => {
const wrapper = mount(
<div>
<Rating type="secondary" />
<Rating type="success" />
<Rating type="warning" />
<Rating type="error" />
</div>,
)
expect(wrapper.html()).toMatchSnapshot()
expect(() => wrapper.unmount()).not.toThrow()
})

it('should show different initialization values', () => {
const wrapper = mount(
<div>
<Rating count={10} value={5} />
<Rating count={2} value={1} />
<Rating count={10} value={10} />
<Rating count={2} value={2} />
</div>,
)
expect(wrapper.html()).toMatchSnapshot()
expect(() => wrapper.unmount()).not.toThrow()
})

it('should initialize state and lock value', () => {
const WrapperRating = () => {
const [value, setValue] = useState<number>(1)
const [lock, setLock] = useState<boolean>(false)

return (
<div>
<Rating
type="success"
value={value}
onLockedChange={setLock}
onValueChange={setValue}
/>
<div id="valueDiv">{value}</div>
<div id="lockDiv">{lock ? 'true' : 'false'}</div>
</div>
)
}
const wrapper = mount(<WrapperRating />)
expect(wrapper.find('svg').children())
expect(wrapper.find('#valueDiv').text()).toContain('1')
expect(wrapper.find('#lockDiv').text()).toContain('false')
})

it('should update state and lock value on click', () => {
const WrapperRating = () => {
const [value, setValue] = useState<number>(1)
const [lock, setLock] = useState<boolean>(false)

return (
<div>
<Rating type="success" onLockedChange={setLock} onValueChange={setValue} />
<div id="valueDiv">{value}</div>
<div id="lockDiv">{lock ? 'true' : 'false'}</div>
</div>
)
}
const wrapper = mount(<WrapperRating />)
expect(wrapper.find('.icon-box').last().simulate('click', nativeEvent))
expect(wrapper.find('#valueDiv').text()).toContain('5')
expect(wrapper.find('#lockDiv').text()).toContain('true')
// unlock again
expect(wrapper.find('.icon-box').last().simulate('click', nativeEvent))
expect(wrapper.find('#valueDiv').text()).toContain('5')
expect(wrapper.find('#lockDiv').text()).toContain('false')
})

it('should update snapshot on mouse enter', () => {
const WrapperRating = () => {
const [value, setValue] = useState<number>(0)
const [lock, setLock] = useState<boolean>(false)

return (
<div>
<Rating type="success" onLockedChange={setLock} onValueChange={setValue} />
<div id="valueDiv">{value}</div>
<div id="lockDiv">{lock ? 'true' : 'false'}</div>
</div>
)
}
const wrapper = mount(<WrapperRating />)
const lastStar = wrapper.find('.icon-box').last()
const firstStar = wrapper.find('.icon-box').first()
expect(lastStar.simulate('mouseenter'))
expect(wrapper.html()).toMatchSnapshot()
expect(lastStar.simulate('click', nativeEvent))
expect(firstStar.simulate('mouseenter'))
expect(wrapper.html()).toMatchSnapshot()
})
})
4 changes: 4 additions & 0 deletions components/rating/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import Rating from './rating'

export type { RatingProps, RatingTypes, RatingCount, RatingValue } from './rating'
export default Rating
21 changes: 21 additions & 0 deletions components/rating/rating-icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import React from 'react'

const RatingIcon: React.FC<unknown> = () => {
return (
<svg
viewBox="0 0 24 24"
width="24"
height="24"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
shapeRendering="geometricPrecision">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
)
}

RatingIcon.displayName = 'GeistRatingIcon'
export default RatingIcon
141 changes: 141 additions & 0 deletions components/rating/rating.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import React, { useEffect, useMemo, useState } from 'react'
import { GeistUIThemesPalette } from '../themes'
import { NormalTypes, tupleNumber } from '../utils/prop-types'
import RatingIcon from './rating-icon'
import useTheme from '../use-theme'
import useScaleable, { withScaleable } from '../use-scaleable'

export type RatingTypes = NormalTypes
const ratingCountTuple = tupleNumber(2, 3, 4, 5, 6, 7, 8, 9, 10)
const ratingValueTuple = tupleNumber(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
export type RatingValue = typeof ratingValueTuple[number]
export type RatingCount = typeof ratingCountTuple[number]

interface Props {
type?: RatingTypes
className?: string
icon?: JSX.Element
count?: RatingCount | number
value?: RatingValue | number
initialValue?: RatingValue
onValueChange?: (value: number) => void
locked?: boolean
onLockedChange?: (locked: boolean) => void
}

const defaultProps = {
type: 'default' as RatingTypes,
className: '',
icon: (<RatingIcon />) as JSX.Element,
count: 5 as RatingCount,
initialValue: 1 as RatingValue,
locked: false,
}

type NativeAttrs = Omit<React.HTMLAttributes<any>, keyof Props>
export type RatingProps = Props & NativeAttrs

const getColor = (type: RatingTypes, palette: GeistUIThemesPalette): string => {
const colors: { [key in RatingTypes]?: string } = {
default: palette.foreground,
success: palette.success,
warning: palette.warning,
error: palette.error,
}
return colors[type] || (colors.default as string)
}

const RatingComponent: React.FC<RatingProps> = ({
type,
children,
className,
icon,
count,
value: customValue,
initialValue,
onValueChange,
locked,
onLockedChange,
...props
}: React.PropsWithChildren<RatingProps> & typeof defaultProps) => {
const theme = useTheme()
const { SCALES } = useScaleable()
const color = useMemo(() => getColor(type, theme.palette), [type, theme.palette])
const [value, setValue] = useState<number>(initialValue)
const [isLocked, setIsLocked] = useState<boolean>(locked)

const lockedChangeHandler = (next: boolean) => {
setIsLocked(next)
onLockedChange && onLockedChange(next)
}
const valueChangeHandler = (next: number) => {
setValue(next)
const emitValue = next > count ? count : next
onValueChange && onValueChange(emitValue)
}
const clickHandler = (index: number) => {
if (isLocked) return lockedChangeHandler(false)
valueChangeHandler(index)
lockedChangeHandler(true)
}
const mouseEnterHandler = (index: number) => {
if (isLocked) return
valueChangeHandler(index)
}

useEffect(() => {
if (typeof customValue === 'undefined') return
setValue(customValue < 0 ? 0 : customValue)
}, [customValue])

return (
<div className={`rating ${className}`} {...props}>
{[...Array(count)].map((_, index) => (
<div
className={`icon-box ${index + 1 <= value ? 'hovered' : ''}`}
key={index}
onMouseEnter={() => mouseEnterHandler(index + 1)}
onClick={() => clickHandler(index + 1)}>
{icon}
</div>
))}
<style jsx>{`
.rating {
box-sizing: border-box;
display: inline-flex;
align-items: center;
--rating-font-size: ${SCALES.font(1)};
font-size: var(--rating-font-size);
width: ${SCALES.width(1, 'auto')};
height: ${SCALES.height(1, 'auto')};
padding: ${SCALES.pt(0)} ${SCALES.pr(0)} ${SCALES.pb(0)} ${SCALES.pl(0)};
margin: ${SCALES.mt(0)} ${SCALES.mr(0)} ${SCALES.mb(0)} ${SCALES.ml(0)};
}
.icon-box {
box-sizing: border-box;
color: ${color};
width: calc(var(--rating-font-size) * 1.5);
height: calc(var(--rating-font-size) * 1.5);
margin-right: calc(var(--rating-font-size) * 1 / 5);
cursor: ${isLocked ? 'default' : 'pointer'};
}
.icon-box :global(svg) {
width: 100%;
height: 100%;
fill: transparent;
transform: scale(1);
transition: transform, color, fill 30ms linear;
}
.hovered :global(svg) {
fill: ${color};
transform: scale(0.9);
}
`}</style>
</div>
)
}

RatingComponent.defaultProps = defaultProps
RatingComponent.displayName = 'GeistRating'
const Rating = withScaleable(RatingComponent)
export default Rating
2 changes: 2 additions & 0 deletions components/utils/prop-types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export const tuple = <T extends string[]>(...args: T) => args

export const tupleNumber = <T extends number[]>(...args: T) => args

const buttonTypes = tuple(
'default',
'secondary',
Expand Down
Loading