Skip to content

Commit

Permalink
feat: APP-2619 - Implement InputNumberMax component (#54)
Browse files Browse the repository at this point in the history
  • Loading branch information
cgero-eth authored Jan 23, 2024
1 parent b6fbe0d commit bb3a152
Show file tree
Hide file tree
Showing 17 changed files with 319 additions and 6 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

### Added

- Implement `InputDate` and `Avatar` components
- Implement `InputDate`, `Avatar` and `InputNumberMax` components
- Add `AvatarIcon` documentation and tests

## [1.0.8] - 2024-01-17
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@
"classnames": "^2.0.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-merge-refs": "^2.0.0"
"react-merge-refs": "^2.0.0",
"react-imask": "^7.3.0"
},
"peerDependencies": {
"tailwindcss": "^3.4.0"
Expand Down
2 changes: 2 additions & 0 deletions src/components/input/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { useInputProps, type IUseInputPropsResult } from './useInputProps';
export { useNumberMask, type IUseNumberMaskProps, type IUseNumberMaskResult } from './useNumberMask';
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import classNames from 'classnames';
import { useEffect, useId, useState, type ChangeEvent, type InputHTMLAttributes } from 'react';
import type { IInputComponentProps, IInputContainerProps } from './inputContainer';
import type { IInputComponentProps, IInputContainerProps } from '../inputContainer';

export interface IUseInputPropsResult {
/**
Expand Down
80 changes: 80 additions & 0 deletions src/components/input/hooks/useNumberMask.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { renderHook } from '@testing-library/react';
import type { InputMask } from 'imask/esm/index';
import * as ReactIMask from 'react-imask';
import { formatterUtils } from '../../../utils';
import { useNumberMask, type IUseNumberMaskResult } from './useNumberMask';

// Mock react-imask library to be able to spy on the useIMask hook
jest.mock('react-imask', () => ({ __esModule: true, ...jest.requireActual('react-imask') }));

describe('useNumberMask hook', () => {
const maskMock = jest.spyOn(ReactIMask, 'useIMask');
const formatNumberMock = jest.spyOn(formatterUtils, 'formatNumber');

beforeEach(() => {
const maskResult = { setValue: jest.fn() } as unknown as IUseNumberMaskResult;
maskMock.mockReturnValue(maskResult);
});

afterEach(() => {
maskMock.mockReset();
formatNumberMock.mockReset();
});

it('returns the result of the useIMask hook', () => {
const maskResult = { setValue: jest.fn(), setUnmaskedValue: jest.fn() } as unknown as IUseNumberMaskResult;
maskMock.mockReturnValue(maskResult);
const { result } = renderHook(() => useNumberMask({}));
expect(result.current).toEqual(maskResult);
});

it('sets the mask to be a number mask with decimals', () => {
renderHook(() => useNumberMask({}));
expect(maskMock).toHaveBeenCalledWith(
expect.objectContaining({ mask: Number, scale: expect.any(Number) }),
expect.anything(),
);
});

it('sets the min and max params to the mask when defined', () => {
const min = 0;
const max = 100;
renderHook(() => useNumberMask({ min, max }));
expect(maskMock).toHaveBeenCalledWith(expect.objectContaining({ min, max }), expect.anything());
});

it('sets the thousand and decimal separator using the current locale', () => {
const thousandsSeparator = ' ';
const radix = '.';
const formattedNumber = `100${thousandsSeparator}000${radix}1`;
formatNumberMock.mockReturnValue(formattedNumber);
renderHook(() => useNumberMask({}));
expect(maskMock).toHaveBeenCalledWith(
expect.objectContaining({ thousandsSeparator, radix }),
expect.anything(),
);
});

it('updates the mask value on value property change for controlled inputs', () => {
const value = '100';
const setValue = jest.fn();
const maskResult = { setValue } as unknown as IUseNumberMaskResult;
maskMock.mockReturnValue(maskResult);

const { rerender } = renderHook((props) => useNumberMask(props), { initialProps: { value } });
expect(setValue).toHaveBeenCalledWith(value);

const newValue = '101';
rerender({ value: newValue });
expect(setValue).toHaveBeenCalledWith(newValue);
});

it('calls the onChange property with the unmasked value when value is valid', () => {
const onChange = jest.fn();
const maskValue = { unmaskedValue: '291829' } as InputMask;
renderHook(() => useNumberMask({ onChange }));
const { onAccept } = maskMock.mock.calls[0][1] ?? {};
onAccept?.('', maskValue);
expect(onChange).toHaveBeenCalledWith(maskValue.unmaskedValue);
});
});
55 changes: 55 additions & 0 deletions src/components/input/hooks/useNumberMask.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useEffect, type ComponentProps } from 'react';
import { useIMask } from 'react-imask';
import { NumberFormat, formatterUtils } from '../../../utils';

export interface IUseNumberMaskProps extends Pick<ComponentProps<'input'>, 'min' | 'max' | 'value'> {
/**
* Callback called on value change. Override the default onChange callback to only emit the updated value because
* the library in use formats the user input and emit the valid number when valid.
*/
onChange?: (value: string) => void;
}

export interface IUseNumberMaskResult extends ReturnType<typeof useIMask<HTMLInputElement>> {}

const getNumberSeparators = () => {
const match = formatterUtils
.formatNumber(100_000.1, { format: NumberFormat.TOKEN_AMOUNT_LONG })
?.match(/([^0-9])/g);

const thousandsSeparator = match?.shift();
const radix = match?.pop();

return { thousandsSeparator, radix };
};

// The imask.js library requires us to set a "scale" property as max decimal places otherwise it defaults to 0.
const maxDecimalPlaces = 30;

export const useNumberMask = (props: IUseNumberMaskProps): IUseNumberMaskResult => {
const { min, max, onChange, value } = props;

const { thousandsSeparator, radix } = getNumberSeparators();

const result = useIMask<HTMLInputElement>(
{
mask: Number,
radix,
thousandsSeparator,
scale: maxDecimalPlaces,
max: max != null ? Number(max) : undefined,
min: min != null ? Number(min) : undefined,
},
{ onAccept: (_value, mask) => onChange?.(mask.unmaskedValue) },
);

const { setValue } = result;

// Update the masked value on value property change
useEffect(() => {
const parsedValue = value?.toString() ?? '';
setValue(parsedValue);
}, [setValue, value]);

return result;
};
1 change: 1 addition & 0 deletions src/components/input/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './inputContainer';
export * from './inputDate';
export * from './inputNumberMax';
export * from './inputSearch';
export * from './inputText';
2 changes: 1 addition & 1 deletion src/components/input/inputDate/inputDate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { forwardRef, useRef } from 'react';
import { mergeRefs } from 'react-merge-refs';
import { Button } from '../../button';
import { IconType } from '../../icon';
import { useInputProps } from '../hooks';
import { InputContainer, type IInputComponentProps } from '../inputContainer';
import { useInputProps } from '../useInputProps';

export interface IInputDateProps extends Omit<IInputComponentProps, 'maxLength'> {}

Expand Down
1 change: 1 addition & 0 deletions src/components/input/inputNumberMax/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { InputNumberMax, type IInputNumberMaxProps } from './inputNumberMax';
46 changes: 46 additions & 0 deletions src/components/input/inputNumberMax/inputNumberMax.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';
import { InputNumberMax, type IInputNumberMaxProps } from './inputNumberMax';

const meta: Meta<typeof InputNumberMax> = {
title: 'components/Input/InputNumberMax',
component: InputNumberMax,
tags: ['autodocs'],
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/jfKRr1V9evJUp1uBeyP3Zz/v1.0.0?type=design&node-id=17-292&mode=design&t=dehPZplRn0YEdOuB-4',
},
},
};

type Story = StoryObj<typeof InputNumberMax>;

/**
* Default usage example of the InputNumberMax component.
*/
export const Default: Story = {
args: {
placeholder: 'Placeholder',
max: 54120,
},
};

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

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

/**
* Usage example of a controlled InputNumberMax component.
*/
export const Controlled: Story = {
render: ({ onChange, ...props }) => <ControlledComponent {...props} />,
args: {
placeholder: 'Controlled input',
max: 120500500.05,
},
};

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

describe('<InputNumberMax /> component', () => {
const useNumberMaskMock = jest.spyOn(InputHooks, 'useNumberMask');

beforeEach(() => {
const numberMaskResult = {
ref: createRef(),
setValue: jest.fn(),
} as unknown as InputHooks.IUseNumberMaskResult;
useNumberMaskMock.mockReturnValue(numberMaskResult);
});

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

const createTestComponent = (props?: Partial<IInputNumberMaxProps>) => {
const completeProps: IInputNumberMaxProps = {
max: 100,
...props,
};

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

it('renders an input with a max button', () => {
render(createTestComponent());
expect(screen.getByRole('textbox')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Max' })).toBeInTheDocument();
});

it('updates the mask value with the max property on max button click', () => {
const max = 1_000_000;
const setValue = jest.fn();
const hookResult = { setValue } as unknown as InputHooks.IUseNumberMaskResult;
useNumberMaskMock.mockReturnValue(hookResult);
render(createTestComponent({ max }));
fireEvent.click(screen.getByRole('button'));
expect(setValue).toHaveBeenCalledWith(max.toString());
});

it('does not render the max button when input is disabled', () => {
const isDisabled = true;
render(createTestComponent({ isDisabled }));
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
});
47 changes: 47 additions & 0 deletions src/components/input/inputNumberMax/inputNumberMax.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import classNames from 'classnames';
import { Button } from '../../button';
import { useInputProps, useNumberMask, type IUseNumberMaskProps } from '../hooks';
import { InputContainer, type IInputComponentProps } from '../inputContainer';

export interface IInputNumberMaxProps extends Omit<IInputComponentProps, 'maxLength' | 'onChange'> {
/**
* Maximum number set on max button click.
*/
max: number;
/**
* @see IUseNumberMaskProps['onChange']
*/
onChange?: IUseNumberMaskProps['onChange'];
}

export const InputNumberMax: React.FC<IInputNumberMaxProps> = (props) => {
const { max, onChange, ...otherProps } = props;
const { containerProps, inputProps } = useInputProps(otherProps);

const { variant, ...otherContainerProps } = containerProps;
const { className: inputClassName, value, min, disabled, ...otherInputProps } = inputProps;

const { ref, setValue } = useNumberMask({ min, max, value, onChange });

const handleMaxClick = () => setValue(max.toString());

return (
<InputContainer variant={variant} {...otherContainerProps}>
<input
className={classNames('spin-buttons:appearance-none', inputClassName)}
ref={ref}
max={max}
min={min}
inputMode="decimal"
disabled={disabled}
{...otherInputProps}
/>
{!disabled && (
<Button size="sm" variant="tertiary" className="mr-2" onClick={handleMaxClick}>
{/* TODO: apply internationalisation to Max label [APP-2627] */}
Max
</Button>
)}
</InputContainer>
);
};
2 changes: 1 addition & 1 deletion src/components/input/inputSearch/inputSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import classNames from 'classnames';
import { useRef, useState, type FocusEvent, type KeyboardEvent } from 'react';
import { Icon, IconType } from '../../icon';
import { Spinner } from '../../spinner';
import { useInputProps } from '../hooks';
import { InputContainer, type IInputComponentProps } from '../inputContainer';
import { useInputProps } from '../useInputProps';

export interface IInputSearchProps extends IInputComponentProps {
/**
Expand Down
2 changes: 1 addition & 1 deletion src/components/input/inputText/inputText.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useInputProps } from '../hooks';
import { InputContainer, type IInputComponentProps } from '../inputContainer';
import { useInputProps } from '../useInputProps';

export interface IInputTextProps extends IInputComponentProps {}

Expand Down
1 change: 1 addition & 0 deletions tailwind.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ module.exports = {
require('tailwindcss/plugin')(({ addVariant }) => {
addVariant('search-cancel', '&::-webkit-search-cancel-button');
addVariant('calendar-icon', ['&::-webkit-calendar-picker-indicator', '&::-webkit-inner-spin-button']);
addVariant('spin-buttons', ['&::-webkit-inner-spin-button', '&::-webkit-outer-spin-button']);
}),
],
};
Loading

0 comments on commit bb3a152

Please sign in to comment.