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

Feature: APP-2729 - Implement Input Number component #72

Merged
merged 11 commits into from
Jan 31, 2024
3 changes: 1 addition & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

### Added

- Implement `Link`, `InputNumber` and `InputTime` components
- Implement 'Addon' element for `InputText` component
- Implement `Link` component
- Handle size property on `Progress` component
- Implement `InputTime` component

### Changed

Expand Down
1 change: 1 addition & 0 deletions src/components/input/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './inputContainer';
export * from './inputDate';
export * from './inputNumber';
export * from './inputNumberMax';
export * from './inputSearch';
export * from './inputText';
Expand Down
1 change: 1 addition & 0 deletions src/components/input/inputNumber/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { InputNumber, type IInputNumberProps } from './inputNumber';
42 changes: 42 additions & 0 deletions src/components/input/inputNumber/inputNumber.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';
import { InputNumber, type IInputNumberProps } from './inputNumber';

const meta: Meta<typeof InputNumber> = {
title: 'components/Input/InputNumber',
component: InputNumber,
tags: ['autodocs'],
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/ISSDryshtEpB7SUSdNqAcw/branch/jfKRr1V9evJUp1uBeyP3Zz/Aragon-ODS?type=design&node-id=10074-6967&mode=design&t=LRQNgdDVgpUu0QIo-0',
},
},
};

type Story = StoryObj<typeof InputNumber>;

/**
* Default usage example of the `InputNumber` component.
*/
export const Default: Story = {
args: {
placeholder: '0',
},
};

const ControlledComponent = (props: IInputNumberProps) => {
const [value, setValue] = useState<string>('1');

return <InputNumber value={value} onChange={setValue} {...props} />;
};

/**
* Usage example of a controlled `InputNumber` component.
*/
export const Controlled: Story = {
args: { suffix: '%' },
render: (props) => <ControlledComponent {...props} />,
};

export default meta;
160 changes: 160 additions & 0 deletions src/components/input/inputNumber/inputNumber.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { IconType } from '../../icon';
import * as InputHooks from '../hooks';
import { InputNumber, type IInputNumberProps } from './inputNumber';

describe('<InputNumber /> component', () => {
const createTestComponent = (props?: Partial<IInputNumberProps>) => {
const completeProps: IInputNumberProps = {
...props,
};

return <InputNumber {...completeProps} />;
};

it('renders an input with increment and decrement buttons', () => {
render(createTestComponent());

expect(screen.getByRole('textbox')).toBeInTheDocument();
expect(screen.getAllByRole('button').length).toEqual(2);
expect(screen.getByTestId(IconType.ADD)).toBeInTheDocument();
expect(screen.getByTestId(IconType.REMOVE)).toBeInTheDocument();
});

it('renders a disabled input with no spin buttons when isDisabled is set to true', () => {
render(createTestComponent({ isDisabled: true }));

expect(screen.getByRole('textbox')).toBeDisabled();
expect(screen.queryAllByRole('button').length).toEqual(0);
});

it('should show suffix & prefix only when there is a value', () => {
let value = '';
const prefix = '~';
const suffix = '%';

const { rerender } = render(createTestComponent({ prefix, suffix }));
expect(screen.queryByDisplayValue(prefix + value + suffix)).not.toBeInTheDocument();

value = 'test';
rerender(createTestComponent({ value, prefix, suffix }));
expect(screen.getByDisplayValue(prefix + value + suffix)).toBeInTheDocument();
});

it('should default step to 1 when given value less than zero', () => {
const step = -15;
render(createTestComponent({ step }));
expect(screen.getByRole('textbox')).toHaveAttribute('step', '1');
});

it('should default step to 1 when given value is zero', () => {
const step = 0;
render(createTestComponent({ step }));
expect(screen.getByRole('textbox')).toHaveAttribute('step', '1');
});

describe('increment button', () => {
const useNumberMaskMock = jest.spyOn(InputHooks, 'useNumberMask');

afterEach(() => {
useNumberMaskMock.mockReset();
});

const testIncrementLogic = ({
expectedValue,
...props
}: Partial<IInputNumberProps> & { expectedValue: string }) => {
const setValue = jest.fn();
const hookResult = {
setValue,
value: props.value,
unmaskedValue: props.value,
} as unknown as InputHooks.IUseNumberMaskResult;
useNumberMaskMock.mockReturnValue(hookResult);

render(createTestComponent({ ...props }));

const [, incrementButton] = screen.getAllByRole<HTMLButtonElement>('button');
fireEvent.click(incrementButton);

expect(setValue).toHaveBeenCalledWith(expectedValue);
};

it('should increment by one (1) with default parameters', () => {
testIncrementLogic({ expectedValue: '1' });
});

it('should return the maximum when the newly generated value exceeds the maximum', () => {
const max = 5;
const step = 2;
const value = '4';
testIncrementLogic({ max, step, value, expectedValue: max.toString() });
});

it('should increment by floating point value when the step is a float', () => {
const value = '1';
const step = 0.5;
testIncrementLogic({ step, value, expectedValue: (Number(value) + step).toString() });
});

it('should round down to the nearest multiple of the step before incrementing by the step value', () => {
const value = '1';
const step = 0.3;
testIncrementLogic({ step, value, expectedValue: '1.2' });
});

it('should increment to the minimum when no value is provided', () => {
const step = 6;
const min = 5;
const max = 10;
testIncrementLogic({ step, min, max, expectedValue: min.toString() });
});
});

describe('decrement button', () => {
const useNumberMaskMock = jest.spyOn(InputHooks, 'useNumberMask');

afterEach(() => {
useNumberMaskMock.mockReset();
});

const testDecrementLogic = ({
expectedValue,
...props
}: Partial<IInputNumberProps> & { expectedValue: string }) => {
const setValue = jest.fn();
const hookResult = {
setValue,
value: props.value,
unmaskedValue: props.value,
} as unknown as InputHooks.IUseNumberMaskResult;
useNumberMaskMock.mockReturnValue(hookResult);

render(createTestComponent({ ...props }));

const [decrementButton] = screen.getAllByRole<HTMLButtonElement>('button');
fireEvent.click(decrementButton);

expect(setValue).toHaveBeenCalledWith(expectedValue);
};

it('should decrement by step', () => {
const value = '10';
const step = 2;
const expectedValue = (10 - 2).toString();
testDecrementLogic({ value, step, expectedValue });
});

it('should decrement to the minimum when no value provided', () => {
const step = 2;
const min = 1;
testDecrementLogic({ step, min, expectedValue: min.toString() });
});

it('should decrement to the closest multiple of the step smaller than the value', () => {
const value = '10';
const step = 3;
testDecrementLogic({ value, step, expectedValue: '9' });
});
});
});
160 changes: 160 additions & 0 deletions src/components/input/inputNumber/inputNumber.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import classNames from 'classnames';
import type React from 'react';
import { useState } from 'react';
import { Button } from '../../button';
import { IconType } from '../../icon';
import { useInputProps, useNumberMask, type IUseNumberMaskProps } from '../hooks';
import { InputContainer, type IInputComponentProps } from '../inputContainer';

export interface IInputNumberProps extends Omit<IInputComponentProps, 'onChange' | 'value' | 'step' | 'min' | 'max'> {
/**
* The minimum value that the number input accepts
*/
min?: number;
/**
* The maximum value that the number input accepts
*/
max?: number;
/**
* Optional string prepended to the input value.
*/
prefix?: string;
/**
* Specifies the granularity of the intervals for the input value
*/
step?: number;
/**
* Optional string appended to the input value.
*/
suffix?: string;
/**
* The value of the number input.
*/
value?: string | number;
/**
* @see IUseNumberMaskProps['onChange']
*/
onChange?: IUseNumberMaskProps['onChange'];
}

export const InputNumber: React.FC<IInputNumberProps> = (props) => {
const {
max = Number.MAX_SAFE_INTEGER,
min = Number.MIN_SAFE_INTEGER,
step: inputStep = 1,
value,
prefix = '',
suffix = '',
onChange,
...otherProps
} = props;

const step = inputStep <= 0 ? 1 : inputStep;
const { containerProps, inputProps } = useInputProps(otherProps);

const { className: containerClassName, isDisabled, ...otherContainerProps } = containerProps;
const { onBlur, onFocus, onKeyDown, className: inputClassName, ...otherInputProps } = inputProps;

const [isFocused, setIsFocused] = useState(false);
const {
ref,
value: maskedValue,
unmaskedValue,
setValue,
} = useNumberMask({
min,
max,
value,
onChange,
});

const augmentedValue = maskedValue ? `${prefix}${maskedValue}${suffix}` : maskedValue;

const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowUp') {
handleIncrement();
} else if (e.key === 'ArrowDown') {
handleDecrement();
}

onKeyDown?.(e);
};

const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
setIsFocused(true);
onFocus?.(e);
};

const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
setIsFocused(false);
onBlur?.(e);
};

const handleIncrement = () => {
const parsedValue = Number(unmaskedValue ?? 0);

// increment directly to the minimum if value is less than the minimum
if (parsedValue < min) {
setValue(min.toString());
return;
}

// ensure value is multiple of step
const newValue = (Math.floor(parsedValue / step) + 1) * step;

// ensure the new value is than the max
setValue(Math.min(max, newValue).toString());
};

const handleDecrement = () => {
const parsedValue = Number(unmaskedValue ?? 0);

// decrement directly to the maximum if value is greater than the maximum
if (parsedValue > max) {
setValue(max.toString());
return;
}

// ensure value is multiple of step
const newValue = (Math.ceil(parsedValue / step) - 1) * step;

// ensure the new value is than the max
setValue(Math.max(min, newValue).toString());
};

return (
<InputContainer className={containerClassName} {...otherContainerProps} isDisabled={isDisabled}>
{!isDisabled && (
<Button
size="sm"
variant="tertiary"
onClick={handleDecrement}
iconLeft={IconType.REMOVE}
className="ml-2 shrink-0"
/>
)}
<input
ref={ref}
step={step}
max={props.max ?? max}
min={props.min ?? min}
onBlur={handleBlur}
onFocus={handleFocus}
inputMode="numeric"
onKeyDown={handleKeyDown}
className={classNames('text-center spin-buttons:appearance-none', inputClassName)}
{...otherInputProps}
value={isFocused ? maskedValue : augmentedValue}
/>
{!isDisabled && (
<Button
size="sm"
variant="tertiary"
iconLeft={IconType.ADD}
onClick={handleIncrement}
className="mr-2 shrink-0"
/>
)}
</InputContainer>
);
};
Loading