Skip to content

Commit

Permalink
feat(Slider)!: support form
Browse files Browse the repository at this point in the history
  • Loading branch information
ValeraS committed Jun 19, 2024
1 parent c61fe11 commit 1262ee7
Show file tree
Hide file tree
Showing 4 changed files with 218 additions and 68 deletions.
5 changes: 3 additions & 2 deletions src/components/Slider/BaseSlider/BaseSlider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import './BaseSlider.scss';
const b = block('base-slider');

type BaseSliderProps = {stateModifiers: StateModifiers} & Omit<
SliderProps,
SliderProps<number | [number, number]>,
'classNames' | 'prefixCls' | 'className' | 'pushable' | 'keyboard'
>;

Expand All @@ -22,6 +22,7 @@ export const BaseSlider = React.forwardRef<SliderRef, BaseSliderProps>(function
ref: React.ForwardedRef<SliderRef>,
) {
return (
// @ts-expect-error Slider value type is (number | number[]) but we use (number | [number, number])
<Slider
{...otherProps}
ref={ref}
Expand All @@ -34,6 +35,6 @@ export const BaseSlider = React.forwardRef<SliderRef, BaseSliderProps>(function
pushable={false}
dots={false}
keyboard={true}
></Slider>
/>
);
});
139 changes: 81 additions & 58 deletions src/components/Slider/Slider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@

import React from 'react';

import debounce from 'lodash/debounce';

import {useControlledState} from '../../hooks';
import {useFormResetHandler} from '../../hooks/private';
import {useDirection} from '../theme';
import {block} from '../utils/cn';
import {filterDOMProps} from '../utils/filterDOMProps';

import {BaseSlider} from './BaseSlider/BaseSlider';
import {SliderTooltip} from './SliderTooltip/SliderTooltip';
import type {RcSliderValueType, SliderProps, SliderValue, StateModifiers} from './types';
import type {SliderProps, StateModifiers} from './types';
import {prepareSliderInnerState} from './utils';

import './Slider.scss';
Expand All @@ -30,46 +31,26 @@ export const Slider = React.forwardRef(function Slider(
errorMessage,
validationState,
disabled = false,
debounceDelay = 0,
onBlur,
onUpdate,
onUpdateComplete,
onFocus,
autoFocus = false,
tabIndex,
className,
style,
qa,
apiRef,
'aria-label': ariaLabelForHandle,
'aria-labelledby': ariaLabelledByForHandle,
name,
form,
id,
...otherProps
}: SliderProps,
ref: React.ForwardedRef<HTMLDivElement>,
) {
const direction = useDirection();
// eslint-disable-next-line react-hooks/exhaustive-deps
const handleUpdate = React.useCallback(
debounce(
(changedValue: RcSliderValueType) => onUpdate?.(changedValue as SliderValue),
debounceDelay,
),
[onUpdate, debounceDelay],
);

// eslint-disable-next-line react-hooks/exhaustive-deps
const handleUpdateComplete = React.useCallback(
debounce(
(changedValue: RcSliderValueType) => onUpdateComplete?.(changedValue as SliderValue),
debounceDelay,
),
[onUpdateComplete, debounceDelay],
);

React.useEffect(() => {
return () => {
handleUpdate.cancel();
handleUpdateComplete.cancel();
};
}, [handleUpdate, handleUpdateComplete]);

const innerState = prepareSliderInnerState({
availableValues,
Expand All @@ -80,6 +61,24 @@ export const Slider = React.forwardRef(function Slider(
step,
value,
});

const [innerValue, setValue] = useControlledState(
innerState.value,
innerState.defaultValue ?? min,
onUpdate,
);

const handleReset = React.useCallback(
(v: number | [number, number]) => {
setValue(v);

onUpdateComplete?.(v);
},
[onUpdateComplete, setValue],
);

const inputRef = useFormResetHandler({initialValue: innerValue, onReset: handleReset});

const stateModifiers: StateModifiers = {
size,
error: validationState === 'invalid' && !disabled,
Expand All @@ -89,12 +88,18 @@ export const Slider = React.forwardRef(function Slider(
};

return (
<div className={b(null, className)} ref={ref}>
<div
{...filterDOMProps(otherProps)}
id={id}
className={b(null, className)}
ref={ref}
style={style}
data-qa={qa}
>
<div className={b('top', {size, hasTooltip})}></div>
<BaseSlider
ref={apiRef}
value={innerState.value}
defaultValue={innerState.defaultValue}
value={innerValue}
min={innerState.min}
max={innerState.max}
step={innerState.step}
Expand All @@ -103,41 +108,59 @@ export const Slider = React.forwardRef(function Slider(
marks={innerState.marks}
onBlur={onBlur}
onFocus={onFocus}
onChange={handleUpdate}
onChangeComplete={handleUpdateComplete}
onChange={setValue}
onChangeComplete={onUpdateComplete}
stateModifiers={stateModifiers}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
tabIndex={tabIndex}
data-qa={qa}
handleRender={
hasTooltip
? (originHandle, handleProps) => {
const styleProp = stateModifiers.rtl ? 'right' : 'left';
return (
<React.Fragment>
{originHandle}
<SliderTooltip
value={handleProps.value}
className={b('tooltip')}
style={{
insetInlineStart:
originHandle.props.style?.[styleProp],
}}
stateModifiers={stateModifiers}
/>
</React.Fragment>
);
}
: undefined
}
handleRender={(originHandle, handleProps) => {
let handle: React.ReactElement = React.cloneElement(originHandle, {
// @ts-expect-error originHandle has incorrect type, actually props is HTMLAttributes<HTMLDivElement>
id: id ? `${id}-handle-${handleProps.index}` : undefined,
'data-qa': qa ? `${qa}-handle-${handleProps.index}` : undefined,
});
if (name) {
handle = (
<React.Fragment>
{handle}
<input
ref={inputRef}
type="hidden"
name={name}
form={form}
value={handleProps.value}
disabled={disabled}
/>
</React.Fragment>
);
}
if (!hasTooltip) {
return handle;
}
const styleProp = stateModifiers.rtl ? 'right' : 'left';
return (
<React.Fragment>
{handle}
<SliderTooltip
value={handleProps.value}
className={b('tooltip')}
style={{
insetInlineStart: originHandle.props.style?.[styleProp],
}}
stateModifiers={stateModifiers}
/>
</React.Fragment>
);
}}
reverse={stateModifiers.rtl}
ariaLabelForHandle={ariaLabelForHandle}
ariaLabelledByForHandle={ariaLabelledByForHandle}
></BaseSlider>
/>
{stateModifiers.error && errorMessage && (
<div className={b('error', {size})}>{errorMessage}</div>
)}
</div>
);
});
}) as <T extends number | [number, number]>(
p: SliderProps<T> & {ref?: React.Ref<HTMLDivElement>},
) => React.ReactElement;
123 changes: 123 additions & 0 deletions src/components/Slider/__tetsts__/Slider.form.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import React from 'react';

import userEvent from '@testing-library/user-event';

import {fireEvent, render, screen} from '../../../../test-utils/utils';
import {Slider} from '../Slider';

describe('Slider form', () => {
it('should submit empty option by default', async () => {
let value;
const onSubmit = jest.fn((e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
value = [...formData.entries()];
});
render(
<form data-qa="form" onSubmit={onSubmit}>
<Slider name="slider" />
<button type="submit" data-qa="submit">
submit
</button>
</form>,
);
await userEvent.click(screen.getByTestId('submit'));
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(value).toEqual([['slider', '0']]);
});

it('should submit default option', async () => {
let value;
const onSubmit = jest.fn((e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
value = [...formData.entries()];
});
render(
<form data-qa="form" onSubmit={onSubmit}>
<Slider name="slider" defaultValue={5} />
<button type="submit" data-qa="submit">
submit
</button>
</form>,
);
await userEvent.click(screen.getByTestId('submit'));
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(value).toEqual([['slider', '5']]);
});

it('should submit multiple option', async () => {
let value;
const onSubmit = jest.fn((e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
value = [...formData.entries()];
});
render(
<form data-qa="form" onSubmit={onSubmit}>
<Slider name="slider" defaultValue={[5, 10]} />
<button type="submit" data-qa="submit">
submit
</button>
</form>,
);
await userEvent.click(screen.getByTestId('submit'));
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(value).toEqual([
['slider', '5'],
['slider', '10'],
]);
});

it('supports form reset', async () => {
function Test() {
const [value, setValue] = React.useState(5);
return (
<form data-qa="form">
<Slider name="slider" value={value} onUpdate={setValue} qa="slider" />
<input type="reset" data-qa="reset" />
</form>
);
}

render(<Test />);
const form = screen.getByTestId('form');
expect(form).toHaveFormValues({slider: '5'});

const sliderHandle = screen.getByTestId('slider-handle-0');
fireEvent.keyDown(sliderHandle, {key: 'ArrowRight', keyCode: 39, code: 'ArrowRight'});
fireEvent.keyDown(sliderHandle, {key: 'ArrowRight', keyCode: 39, code: 'ArrowRight'});

expect(form).toHaveFormValues({slider: '7'});

const button = screen.getByTestId('reset');
await userEvent.click(button);
expect(form).toHaveFormValues({slider: '5'});
});

it('supports form reset range value', async () => {
function Test() {
const [value, setValue] = React.useState<[number, number]>([5, 10]);
return (
<form data-qa="form">
<Slider name="slider" value={value} onUpdate={setValue} qa="slider" />
<input type="reset" data-qa="reset" />
</form>
);
}

render(<Test />);
const form = screen.getByTestId('form');
expect(form).toHaveFormValues({slider: ['5', '10']});

const sliderHandle = screen.getByTestId('slider-handle-1');
fireEvent.keyDown(sliderHandle, {key: 'ArrowRight', keyCode: 39, code: 'ArrowRight'});
fireEvent.keyDown(sliderHandle, {key: 'ArrowRight', keyCode: 39, code: 'ArrowRight'});

expect(form).toHaveFormValues({slider: ['5', '12']});

const button = screen.getByTestId('reset');
await userEvent.click(button);
expect(form).toHaveFormValues({slider: ['5', '10']});
});
});
19 changes: 11 additions & 8 deletions src/components/Slider/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export type SliderValue = number | [number, number];

export type RcSliderValueType = number | number[];

export type SliderProps<ValueType = number | [number, number]> = {
export interface SliderProps<ValueType = number | [number, number]> extends DOMProps, QAProps {
/** The value of the control */
value?: ValueType;
/** The control's default value, used when the component is not controlled */
Expand All @@ -35,8 +35,6 @@ export type SliderProps<ValueType = number | [number, number]> = {
/** Describes the validation state */
validationState?: 'invalid';

/** Specifies the delay (in milliseconds) before the processing function is called */
debounceDelay?: number;
/** Fires when the control gets focus. Provides focus event as a callback's argument */
onFocus?: (e: React.FocusEvent<HTMLDivElement>) => void;
/** Fires when the control lost focus. Provides focus event as a callback's argument */
Expand All @@ -52,15 +50,20 @@ export type SliderProps<ValueType = number | [number, number]> = {
tabIndex?: ValueType;
/** Ref to Slider's component props of focus and blur */
apiRef?: React.RefObject<BaseSliderRefType>;
'aria-label'?: string;
'aria-labelledby'?: string;
} & Pick<DOMProps, 'className'> &
QAProps;
'aria-label'?: string | [string, string];
'aria-labelledby'?: string | [string, string];
id?: string;
/** Name attribute of the hidden input element. */
name?: string;
form?: string;
}

export type SliderInnerState = {
max: number;
min: number;
} & Pick<RcSliderProps, 'value' | 'defaultValue' | 'step' | 'range' | 'marks'>;
value?: number | [number, number];
defaultValue?: number | [number, number];
} & Pick<RcSliderProps, 'step' | 'range' | 'marks'>;

export type StateModifiers = {
size: SliderSize;
Expand Down

0 comments on commit 1262ee7

Please sign in to comment.