diff --git a/README.md b/README.md index 2592a32..94067b4 100644 --- a/README.md +++ b/README.md @@ -102,9 +102,11 @@ Example if `fixedDecimalLength` was 2: | id | `string` | | Component id | | maxLength | `number` | | Maximum characters the user can enter | | onChange | `function` | | Handle change in value | +| onBlurValue | `function` | | Handle value onBlur | | placeholder | `string` | | Placeholder if no value | | precision | `number` | | Specify decimal precision for padding/trimming | | prefix | `string` | | Include a prefix eg. £ or \$ | +| step | `number` | | Incremental value change on arrow down and arrow up key press | | decimalSeparator | `string` | `.` | Separator between integer part and fractional part of value | | groupSeparator | `string` | `,` | Separator between thousand, million and billion | | turnOffSeparators | `boolean` | `false` | Disable auto adding the group separator between values, eg. 1000 > 1,000 | diff --git a/src/components/CurrencyInput.tsx b/src/components/CurrencyInput.tsx index 1cebb57..5ba8ec2 100644 --- a/src/components/CurrencyInput.tsx +++ b/src/components/CurrencyInput.tsx @@ -12,13 +12,14 @@ export const CurrencyInput: FC<CurrencyInputProps> = ({ defaultValue, disabled = false, maxLength: userMaxLength, - value, + value: userValue, onChange, onBlurValue, fixedDecimalLength, placeholder, precision, prefix, + step, decimalSeparator = '.', groupSeparator = ',', turnOffSeparators = false, @@ -62,10 +63,8 @@ export const CurrencyInput: FC<CurrencyInputProps> = ({ const onFocus = (): number => (stateValue ? stateValue.length : 0); - const processChange = ({ - target: { value, selectionStart }, - }: React.ChangeEvent<HTMLInputElement>): void => { - const valueOnly = cleanValue({ value: String(value), ...cleanValueOptions }); + const processChange = (value: string, selectionStart?: number | null): void => { + const valueOnly = cleanValue({ value, ...cleanValueOptions }); if (!valueOnly) { onChange && onChange(undefined, name); @@ -73,7 +72,7 @@ export const CurrencyInput: FC<CurrencyInputProps> = ({ return; } - if (userMaxLength && valueOnly.length > userMaxLength) { + if (userMaxLength && valueOnly.replace(/-/g, '').length > userMaxLength) { return; } @@ -86,7 +85,7 @@ export const CurrencyInput: FC<CurrencyInputProps> = ({ const formattedValue = formatValue({ value: valueOnly, ...formatValueOptions }); /* istanbul ignore next */ - if (selectionStart) { + if (selectionStart !== undefined && selectionStart !== null) { const cursor = selectionStart + (formattedValue.length - value.length) || 1; setCursor(cursor); } @@ -96,6 +95,12 @@ export const CurrencyInput: FC<CurrencyInputProps> = ({ onChange && onChange(valueOnly, name); }; + const handleOnChange = ({ + target: { value, selectionStart }, + }: React.ChangeEvent<HTMLInputElement>): void => { + processChange(value, selectionStart); + }; + const handleOnBlur = ({ target: { value } }: React.ChangeEvent<HTMLInputElement>): void => { const valueOnly = cleanValue({ value, ...cleanValueOptions }); @@ -116,6 +121,23 @@ export const CurrencyInput: FC<CurrencyInputProps> = ({ setStateValue(formattedValue); }; + const handleOnKeyDown = ({ key }: React.KeyboardEvent<HTMLInputElement>) => { + if (step && (key === 'ArrowUp' || key === 'ArrowDown')) { + const currentValue = + Number( + userValue !== undefined + ? userValue + : cleanValue({ value: stateValue, ...cleanValueOptions }) + ) || 0; + const newValue = + key === 'ArrowUp' + ? String(currentValue + Number(step)) + : String(currentValue - Number(step)); + + processChange(newValue); + } + }; + /* istanbul ignore next */ useEffect(() => { if (inputRef && inputRef.current) { @@ -124,7 +146,9 @@ export const CurrencyInput: FC<CurrencyInputProps> = ({ }, [cursor, inputRef]); const formattedPropsValue = - value !== undefined ? formatValue({ value: String(value), ...formatValueOptions }) : undefined; + userValue !== undefined + ? formatValue({ value: String(userValue), ...formatValueOptions }) + : undefined; return ( <input @@ -133,9 +157,10 @@ export const CurrencyInput: FC<CurrencyInputProps> = ({ id={id} name={name} className={className} - onChange={processChange} + onChange={handleOnChange} onBlur={handleOnBlur} onFocus={onFocus} + onKeyDown={handleOnKeyDown} placeholder={placeholder} disabled={disabled} value={ diff --git a/src/components/CurrencyInputProps.ts b/src/components/CurrencyInputProps.ts index 07d9740..0629932 100644 --- a/src/components/CurrencyInputProps.ts +++ b/src/components/CurrencyInputProps.ts @@ -84,6 +84,11 @@ export type CurrencyInputProps = Overwrite< */ prefix?: string; + /** + * Incremental value change on arrow down and arrow up key press + */ + step?: number; + /** * Separator between integer part and fractional part of value. Cannot be a number * diff --git a/src/components/__tests__/CurrencyInput-handleKeyDown.spec.tsx b/src/components/__tests__/CurrencyInput-handleKeyDown.spec.tsx new file mode 100644 index 0000000..e26fe1b --- /dev/null +++ b/src/components/__tests__/CurrencyInput-handleKeyDown.spec.tsx @@ -0,0 +1,155 @@ +import { shallow } from 'enzyme'; +import React from 'react'; +import CurrencyInput from '../CurrencyInput'; + +const id = 'validationCustom01'; + +describe('<CurrencyInput /> component > handleKeyDown', () => { + const onChangeSpy = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should not change value if no step prop', () => { + const view = shallow( + <CurrencyInput id={id} prefix="£" defaultValue={100} onChange={onChangeSpy} /> + ); + + // Arrow up + view.find(`#${id}`).simulate('keyDown', { key: 'ArrowUp' }); + expect(onChangeSpy).not.toBeCalled(); + expect(view.update().find(`#${id}`).prop('value')).toBe('£100'); + + // Arrow down + view.find(`#${id}`).simulate('keyDown', { key: 'ArrowDown' }); + expect(onChangeSpy).not.toBeCalled(); + expect(view.update().find(`#${id}`).prop('value')).toBe('£100'); + }); + + it('should handle negative step', () => { + const view = shallow( + <CurrencyInput id={id} prefix="£" defaultValue={100} step={-2} onChange={onChangeSpy} /> + ); + + view.find(`#${id}`).simulate('keyDown', { key: 'ArrowUp' }); + expect(onChangeSpy).toHaveBeenCalledWith('98', undefined); + expect(view.update().find(`#${id}`).prop('value')).toBe('£98'); + + view.find(`#${id}`).simulate('keyDown', { key: 'ArrowDown' }); + expect(onChangeSpy).toHaveBeenCalledWith('100', undefined); + expect(view.update().find(`#${id}`).prop('value')).toBe('£100'); + }); + + describe('without value ie. default 0', () => { + it('should handle arrow down key', () => { + const view = shallow(<CurrencyInput id={id} prefix="£" step={1} onChange={onChangeSpy} />); + + view.find(`#${id}`).simulate('keyDown', { key: 'ArrowDown' }); + expect(onChangeSpy).toBeCalledWith('-1', undefined); + expect(view.update().find(`#${id}`).prop('value')).toBe('-£1'); + }); + + it('should handle arrow down key', () => { + const view = shallow(<CurrencyInput id={id} prefix="£" step={1} onChange={onChangeSpy} />); + + view.find(`#${id}`).simulate('keyDown', { key: 'ArrowUp' }); + expect(onChangeSpy).toBeCalledWith('1', undefined); + expect(view.update().find(`#${id}`).prop('value')).toBe('£1'); + }); + }); + + describe('with value 99 and step 1.25', () => { + it('should handle arrow down key', () => { + const view = shallow( + <CurrencyInput id={id} prefix="£" value={99} step={1.25} onChange={onChangeSpy} /> + ); + + view.find(`#${id}`).simulate('keyDown', { key: 'ArrowDown' }); + expect(onChangeSpy).toHaveBeenCalledWith('97.75', undefined); + }); + + it('should handle arrow up key', () => { + const view = shallow( + <CurrencyInput id={id} prefix="£" value={99} step={1.25} onChange={onChangeSpy} /> + ); + + view.find(`#${id}`).simulate('keyDown', { key: 'ArrowUp' }); + expect(onChangeSpy).toHaveBeenCalledWith('100.25', undefined); + }); + }); + + describe('with defaultValue 100 and step 5.5', () => { + it('should handle arrow down key', () => { + const view = shallow( + <CurrencyInput id={id} prefix="£" defaultValue={100} step={5.5} onChange={onChangeSpy} /> + ); + + view.find(`#${id}`).simulate('keyDown', { key: 'ArrowDown' }); + expect(onChangeSpy).toBeCalledWith('94.5', undefined); + expect(view.update().find(`#${id}`).prop('value')).toBe('£94.5'); + + view.find(`#${id}`).simulate('keyDown', { key: 'ArrowDown' }); + expect(onChangeSpy).toBeCalledWith('89', undefined); + expect(view.update().find(`#${id}`).prop('value')).toBe('£89'); + }); + + it('should handle arrow up key', () => { + const view = shallow( + <CurrencyInput id={id} prefix="£" defaultValue={100} step={5.5} onChange={onChangeSpy} /> + ); + + view.find(`#${id}`).simulate('keyDown', { key: 'ArrowUp' }); + expect(onChangeSpy).toBeCalledWith('105.5', undefined); + expect(view.update().find(`#${id}`).prop('value')).toBe('£105.5'); + + view.find(`#${id}`).simulate('keyDown', { key: 'ArrowUp' }); + expect(onChangeSpy).toBeCalledWith('111', undefined); + expect(view.update().find(`#${id}`).prop('value')).toBe('£111'); + }); + }); + + describe('with max length 2', () => { + it('should handle negative value', () => { + const view = shallow( + <CurrencyInput + id={id} + prefix="£" + defaultValue={-99} + step={1} + maxLength={2} + onChange={onChangeSpy} + /> + ); + + view.find(`#${id}`).simulate('keyDown', { key: 'ArrowDown' }); + expect(onChangeSpy).not.toBeCalled(); + expect(view.update().find(`#${id}`).prop('value')).toBe('-£99'); + + view.find(`#${id}`).simulate('keyDown', { key: 'ArrowUp' }); + expect(onChangeSpy).toHaveBeenCalledWith('-98', undefined); + expect(view.update().find(`#${id}`).prop('value')).toBe('-£98'); + }); + + it('should handle positive value', () => { + const view = shallow( + <CurrencyInput + id={id} + prefix="£" + defaultValue={99} + step={1} + maxLength={2} + onChange={onChangeSpy} + /> + ); + + view.find(`#${id}`).simulate('keyDown', { key: 'ArrowUp' }); + expect(onChangeSpy).not.toBeCalled(); + expect(view.update().find(`#${id}`).prop('value')).toBe('£99'); + + view.find(`#${id}`).simulate('keyDown', { key: 'ArrowDown' }); + expect(onChangeSpy).toHaveBeenCalledWith('98', undefined); + expect(view.update().find(`#${id}`).prop('value')).toBe('£98'); + }); + }); +}); diff --git a/src/components/__tests__/CurrencyInput-maxLength.spec.tsx b/src/components/__tests__/CurrencyInput-maxLength.spec.tsx new file mode 100644 index 0000000..553263b --- /dev/null +++ b/src/components/__tests__/CurrencyInput-maxLength.spec.tsx @@ -0,0 +1,59 @@ +import { shallow } from 'enzyme'; +import React from 'react'; +import CurrencyInput from '../CurrencyInput'; + +const id = 'validationCustom01'; + +describe('<CurrencyInput /> component > maxLength', () => { + const onChangeSpy = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should not allow more values than max length', () => { + const view = shallow( + <CurrencyInput + id={id} + name={name} + prefix="£" + onChange={onChangeSpy} + maxLength={3} + defaultValue={123} + /> + ); + + const input = view.find(`#${id}`); + expect(input.prop('value')).toBe('£123'); + + input.simulate('change', { target: { value: '£1234' } }); + expect(onChangeSpy).not.toBeCalled(); + + const updatedView = view.update(); + expect(updatedView.find(`#${id}`).prop('value')).toBe('£123'); + }); + + it('should apply max length rule to negative value', () => { + const view = shallow( + <CurrencyInput + id={id} + name={name} + prefix="£" + onChange={onChangeSpy} + maxLength={3} + defaultValue={-123} + /> + ); + + const input = view.find(`#${id}`); + expect(input.prop('value')).toBe('-£123'); + + input.simulate('change', { target: { value: '-£1234' } }); + expect(onChangeSpy).not.toBeCalled(); + expect(view.update().find(`#${id}`).prop('value')).toBe('-£123'); + + input.simulate('change', { target: { value: '-£125' } }); + expect(onChangeSpy).toHaveBeenCalledWith('-125', ''); + expect(view.update().find(`#${id}`).prop('value')).toBe('-£125'); + }); +}); diff --git a/src/components/__tests__/CurrencyInput.spec.tsx b/src/components/__tests__/CurrencyInput.spec.tsx index 8e05673..e6fc30d 100644 --- a/src/components/__tests__/CurrencyInput.spec.tsx +++ b/src/components/__tests__/CurrencyInput.spec.tsx @@ -48,14 +48,19 @@ describe('<CurrencyInput /> component', () => { }); it('Renders with value prop', () => { - const value = 49.99; - - const view = shallow(<CurrencyInput id={id} value={value} className={className} prefix="£" />); + const view = shallow(<CurrencyInput id={id} value={49.99} className={className} prefix="£" />); const input = view.find(`#${id}`); expect(input.prop('value')).toBe('£49.99'); }); + it('Renders with value 0', () => { + const view = shallow(<CurrencyInput id={id} value={0} className={className} prefix="£" />); + const input = view.find(`#${id}`); + + expect(input.prop('value')).toBe('£0'); + }); + it('should go to end of string on focus', () => { const view = shallow(<CurrencyInput id={id} defaultValue={123} />); view.find(`#${id}`).simulate('focus'); @@ -88,7 +93,7 @@ describe('<CurrencyInput /> component', () => { it('should allow 0 value on change', () => { const view = shallow(<CurrencyInput id={id} name={name} prefix="£" onChange={onChangeSpy} />); - view.find(`#${id}`).simulate('change', { target: { value: 0 } }); + view.find(`#${id}`).simulate('change', { target: { value: '0' } }); expect(onChangeSpy).toBeCalledWith('0', name); const updatedView = view.update(); @@ -127,28 +132,4 @@ describe('<CurrencyInput /> component', () => { const updatedView = view.update(); expect(updatedView.find(`#${id}`).prop('value')).toBe('£123'); }); - - describe('maxLength', () => { - it('should not allow more values than max length', () => { - const view = shallow( - <CurrencyInput - id={id} - name={name} - prefix="£" - onChange={onChangeSpy} - maxLength={3} - defaultValue={123} - /> - ); - - const input = view.find(`#${id}`); - expect(input.prop('value')).toBe('£123'); - - input.simulate('change', { target: { value: '£1234' } }); - expect(onChangeSpy).not.toBeCalled(); - - const updatedView = view.update(); - expect(updatedView.find(`#${id}`).prop('value')).toBe('£123'); - }); - }); }); diff --git a/src/examples/Example1.tsx b/src/examples/Example1.tsx index b58ea4a..e50acab 100644 --- a/src/examples/Example1.tsx +++ b/src/examples/Example1.tsx @@ -73,6 +73,7 @@ export const Example1: FC = () => { onBlurValue={handleOnBlurValue} prefix={prefix} precision={2} + step={1} /> <div className="invalid-feedback">{errorMessage}</div> </div> diff --git a/src/examples/Example2.tsx b/src/examples/Example2.tsx index 3f1c24e..3827c52 100644 --- a/src/examples/Example2.tsx +++ b/src/examples/Example2.tsx @@ -51,6 +51,7 @@ export const Example2: FC = () => { onChange={validateValue} onBlurValue={handleOnBlurValue} prefix={'$'} + step={10} /> <div className="invalid-feedback">{errorMessage}</div> </div> diff --git a/src/examples/FormatValuesExample.tsx b/src/examples/FormatValuesExample.tsx index ef67bfb..6810435 100644 --- a/src/examples/FormatValuesExample.tsx +++ b/src/examples/FormatValuesExample.tsx @@ -2,7 +2,7 @@ import React, { FC, useState } from 'react'; import { formatValue } from '../components/utils'; const FormatValuesExample: FC = () => { - const [value, setValue] = useState('123456789.999999'); + const [value, setValue] = useState('123456789.999'); const [prefix, setPrefix] = useState('$'); const [groupSeparator, setGroupSeparator] = useState(','); const [decimalSeparator, setDecimalSeparator] = useState('.'); @@ -115,7 +115,7 @@ const FormatValuesExample: FC = () => { </div> <div className="mt-5"> Formatted value: - <div className="display-2"> + <div className="display-4"> {formatValue({ value, groupSeparator,