-
Notifications
You must be signed in to change notification settings - Fork 45
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(core-components-slider): add component (#285)
* feat(core-components-slider): add component * feat(core-components-slider): change cursor * fix(core-components-slider): add missing deps * fix(core-components-slider): pr fixes
- Loading branch information
Showing
8 changed files
with
481 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
{ | ||
"name": "@alfalab/core-components-slider", | ||
"version": "1.0.0", | ||
"description": "Slider component", | ||
"keywords": [], | ||
"license": "ISC", | ||
"main": "dist/index.js", | ||
"files": [ | ||
"dist" | ||
], | ||
"publishConfig": { | ||
"access": "public" | ||
}, | ||
"peerDependencies": { | ||
"classnames": "^2.2.6", | ||
"react": "^16.9.0", | ||
"react-dom": "^16.9.0" | ||
}, | ||
"dependencies": { | ||
"@alfalab/hooks": "^0.9.0", | ||
"react-merge-refs": "^1.1.0" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
import { Meta, Story, Preview, Props } from '@storybook/addon-docs/blocks'; | ||
import { action } from '@storybook/addon-actions'; | ||
import { text, select, boolean, number } from '@storybook/addon-knobs'; | ||
import { Container, Row, Col } from 'storybook/blocks/grid'; | ||
import { ComponentHeader } from 'storybook/blocks/component-header'; | ||
import Icon from '@alfalab/icons-glyph/StarMIcon'; | ||
|
||
import { Slider } from './Component'; | ||
import { name, version } from '../package.json'; | ||
|
||
|
||
<Meta | ||
title='Компоненты' | ||
component={Slider} | ||
/> | ||
|
||
|
||
<!-- Canvas --> | ||
|
||
<Story name='Slider'> | ||
{React.createElement(() => { | ||
const [value, setValue] = React.useState(50); | ||
const handleChange = (event, { value }) => setValue(value); | ||
return ( | ||
<Slider | ||
value={value} | ||
onChange={handleChange} | ||
min={number('min', 0)} | ||
max={number('max', 100)} | ||
step={number('step', 1)} | ||
/> | ||
); | ||
})} | ||
</Story> | ||
|
||
|
||
<!-- Docs --> | ||
|
||
<ComponentHeader | ||
name='Slider' | ||
version={version} | ||
package='@alfalab/core-components-slider' | ||
stage={0} | ||
design='https://www.figma.com/file/hqSP3L6qu8UcL3sf18Su1M/Web-Components?node-id=11780%3A5392' | ||
/> | ||
|
||
```tsx | ||
import { Slider } from '@alfalab/core-components-slider'; | ||
``` | ||
|
||
<Preview> | ||
{React.createElement(() => { | ||
const [value, setValue] = React.useState(50); | ||
const handleChange = (event, { value }) => setValue(value); | ||
return ( | ||
<Slider value={value} onChange={handleChange} /> | ||
); | ||
})} | ||
</Preview> | ||
|
||
<Props of={Slider} /> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,112 @@ | ||
import React from 'react'; | ||
import { render, fireEvent } from '@testing-library/react'; | ||
|
||
import { Slider } from './index'; | ||
|
||
describe('Slider', () => { | ||
it('should match snapshot', () => { | ||
const { container } = render(<Slider />); | ||
expect(container).toMatchSnapshot(); | ||
}); | ||
|
||
it('should set `data-test-id` attribute', () => { | ||
const dataTestId = 'test-id'; | ||
const { getByTestId } = render(<Slider dataTestId={dataTestId} />); | ||
|
||
expect(getByTestId(dataTestId)).toBeTruthy(); | ||
}); | ||
|
||
it('should forward ref to input element', () => { | ||
const ref = jest.fn(); | ||
const dataTestId = 'test-id'; | ||
|
||
render(<Slider ref={ref} dataTestId={dataTestId} />); | ||
|
||
expect(ref.mock.calls[0][0].tagName).toBe('INPUT'); | ||
}); | ||
|
||
it('should set `className` class to root', () => { | ||
const className = 'test-class'; | ||
const { container } = render(<Slider className={className} />); | ||
|
||
expect(container.firstElementChild).toHaveClass(className); | ||
}); | ||
|
||
it('should set min, max correctly', () => { | ||
const dataTestId = 'test-id'; | ||
const min = 5; | ||
const max = 50; | ||
const { getByRole } = render(<Slider min={min} max={max} dataTestId={dataTestId} />); | ||
const slider = getByRole('slider') as HTMLInputElement; | ||
const progress = getByRole('progressbar') as HTMLProgressElement; | ||
|
||
expect(slider.min).toBe(min.toString()); | ||
expect(slider.max).toBe(max.toString()); | ||
|
||
expect(progress.max).toBe(max - min); | ||
}); | ||
|
||
it('should set step = 1 if (max - min) % step !== 0', () => { | ||
const dataTestId = 'test-id'; | ||
const step = 3; | ||
const { getByRole } = render( | ||
<Slider min={0} max={7} step={step} dataTestId={dataTestId} />, | ||
); | ||
const slider = getByRole('slider') as HTMLInputElement; | ||
|
||
expect(slider.step).toBeFalsy(); | ||
}); | ||
|
||
it('should set value correctly', () => { | ||
const dataTestId = 'test-id'; | ||
const value = 10; | ||
const min = 5; | ||
const max = 50; | ||
const step = 5; | ||
const { getByRole } = render( | ||
<Slider value={value} min={min} max={max} step={step} dataTestId={dataTestId} />, | ||
); | ||
|
||
expect((getByRole('slider') as HTMLInputElement).value).toBe(value.toString()); | ||
expect((getByRole('progressbar') as HTMLProgressElement).value).toBe(value - min); | ||
}); | ||
|
||
it('should set out of bounds value in range [min, max]', () => { | ||
const dataTestId = 'test-id'; | ||
const min = 5; | ||
const max = 55; | ||
const valueBelowMinimum = 4; | ||
const valueAboveMaximum = 56; | ||
|
||
const { getByRole, rerender } = render( | ||
<Slider value={valueBelowMinimum} min={min} max={max} dataTestId={dataTestId} />, | ||
); | ||
|
||
expect((getByRole('slider') as HTMLInputElement).value).toBe(min.toString()); | ||
expect((getByRole('progressbar') as HTMLProgressElement).value).toBe(0); | ||
|
||
rerender(<Slider value={valueAboveMaximum} min={min} max={max} dataTestId={dataTestId} />); | ||
|
||
expect((getByRole('slider') as HTMLInputElement).value).toBe(max.toString()); | ||
expect((getByRole('progressbar') as HTMLProgressElement).value).toBe(max - min); | ||
}); | ||
|
||
it('should call `onChange` prop', () => { | ||
const cb = jest.fn(); | ||
const dataTestId = 'test-id'; | ||
const value = 10; | ||
const { getByRole } = render(<Slider onChange={cb} dataTestId={dataTestId} />); | ||
|
||
const input = getByRole('slider') as HTMLInputElement; | ||
|
||
fireEvent.change(input, { target: { value } }); | ||
|
||
expect(cb).toBeCalledTimes(1); | ||
}); | ||
|
||
it('should unmount without errors', () => { | ||
const { unmount } = render(<Slider />); | ||
|
||
expect(unmount).not.toThrowError(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
import React, { forwardRef, useRef, InputHTMLAttributes, useCallback, ChangeEvent } from 'react'; | ||
import cn from 'classnames'; | ||
import mergeRefs from 'react-merge-refs'; | ||
import { useFocus } from '@alfalab/hooks'; | ||
|
||
import styles from './index.module.css'; | ||
|
||
type NativeProps = Omit< | ||
InputHTMLAttributes<HTMLInputElement>, | ||
'min' | 'max' | 'step' | 'value' | 'type' | ||
>; | ||
|
||
export type SliderProps = NativeProps & { | ||
/** | ||
* Мин. допустимое число | ||
*/ | ||
min?: number; | ||
|
||
/** | ||
* Макс. допустимое число | ||
*/ | ||
max?: number; | ||
|
||
/** | ||
* Шаг (должен нацело делить отрезок между мин и макс) | ||
*/ | ||
step?: number; | ||
|
||
/** | ||
* Значение инпута | ||
*/ | ||
value?: number; | ||
|
||
/** | ||
* Обработчик поля ввода | ||
*/ | ||
onChange?: (event: ChangeEvent<HTMLInputElement>, payload: { value: number }) => void; | ||
|
||
/** | ||
* Идентификатор для систем автоматизированного тестирования | ||
*/ | ||
dataTestId?: string; | ||
}; | ||
|
||
export const Slider = forwardRef<HTMLInputElement, SliderProps>( | ||
( | ||
{ min = 0, max = 100, step = 1, value = 0, className, onChange, dataTestId, ...restProps }, | ||
ref, | ||
) => { | ||
const inputRef = useRef<HTMLInputElement>(null); | ||
|
||
const [focused] = useFocus(inputRef, 'keyboard'); | ||
|
||
const range = max - min; | ||
const dividedWithoutRemainder = range % step === 0; | ||
const validValue = Math.max(min, Math.min(value, max)); | ||
|
||
const rangeProps = { | ||
className: cn(styles.range, { [styles.focused]: focused }), | ||
type: 'range', | ||
min, | ||
max, | ||
value: validValue, | ||
step: dividedWithoutRemainder ? step : undefined, | ||
}; | ||
|
||
const progressProps = { | ||
className: styles.progress, | ||
max: range, | ||
value: validValue - min, | ||
}; | ||
|
||
const handleInputChange = useCallback( | ||
(event: ChangeEvent<HTMLInputElement>) => { | ||
if (onChange) { | ||
onChange(event, { value: +event.target.value }); | ||
} | ||
}, | ||
[onChange], | ||
); | ||
|
||
return ( | ||
<div className={cn(styles.component, className)} data-test-id={dataTestId}> | ||
<div className={styles.rangeWrapper}> | ||
<input | ||
{...rangeProps} | ||
{...restProps} | ||
ref={mergeRefs([ref, inputRef])} | ||
onChange={handleInputChange} | ||
/> | ||
</div> | ||
<progress {...progressProps} /> | ||
</div> | ||
); | ||
}, | ||
); | ||
|
||
/** | ||
* Для отображения в сторибуке | ||
*/ | ||
Slider.defaultProps = { | ||
min: 0, | ||
max: 100, | ||
step: 1, | ||
value: 0, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
// Jest Snapshot v1, https://goo.gl/fbAQLP | ||
|
||
exports[`Slider should match snapshot 1`] = ` | ||
<div> | ||
<div | ||
class="component" | ||
> | ||
<div | ||
class="rangeWrapper" | ||
> | ||
<input | ||
class="range" | ||
max="100" | ||
min="0" | ||
step="1" | ||
type="range" | ||
value="0" | ||
/> | ||
</div> | ||
<progress | ||
class="progress" | ||
max="100" | ||
value="0" | ||
/> | ||
</div> | ||
</div> | ||
`; |
Oops, something went wrong.