diff --git a/CHANGELOG.md b/CHANGELOG.md index bd8f72430..b2c047d50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package.json b/package.json index b742fcc76..989e284fb 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/components/input/hooks/index.ts b/src/components/input/hooks/index.ts new file mode 100644 index 000000000..d8d696abf --- /dev/null +++ b/src/components/input/hooks/index.ts @@ -0,0 +1,2 @@ +export { useInputProps, type IUseInputPropsResult } from './useInputProps'; +export { useNumberMask, type IUseNumberMaskProps, type IUseNumberMaskResult } from './useNumberMask'; diff --git a/src/components/input/useInputProps.test.ts b/src/components/input/hooks/useInputProps.test.ts similarity index 100% rename from src/components/input/useInputProps.test.ts rename to src/components/input/hooks/useInputProps.test.ts diff --git a/src/components/input/useInputProps.ts b/src/components/input/hooks/useInputProps.ts similarity index 99% rename from src/components/input/useInputProps.ts rename to src/components/input/hooks/useInputProps.ts index 7f6cea91c..cdf67bf35 100644 --- a/src/components/input/useInputProps.ts +++ b/src/components/input/hooks/useInputProps.ts @@ -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 { /** diff --git a/src/components/input/hooks/useNumberMask.test.ts b/src/components/input/hooks/useNumberMask.test.ts new file mode 100644 index 000000000..141a19cb5 --- /dev/null +++ b/src/components/input/hooks/useNumberMask.test.ts @@ -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); + }); +}); diff --git a/src/components/input/hooks/useNumberMask.ts b/src/components/input/hooks/useNumberMask.ts new file mode 100644 index 000000000..082782af2 --- /dev/null +++ b/src/components/input/hooks/useNumberMask.ts @@ -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, '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> {} + +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( + { + 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; +}; diff --git a/src/components/input/index.ts b/src/components/input/index.ts index c2a1910e1..830183a28 100644 --- a/src/components/input/index.ts +++ b/src/components/input/index.ts @@ -1,4 +1,5 @@ export * from './inputContainer'; export * from './inputDate'; +export * from './inputNumberMax'; export * from './inputSearch'; export * from './inputText'; diff --git a/src/components/input/inputDate/inputDate.tsx b/src/components/input/inputDate/inputDate.tsx index cc524a4f3..144311e54 100644 --- a/src/components/input/inputDate/inputDate.tsx +++ b/src/components/input/inputDate/inputDate.tsx @@ -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 {} diff --git a/src/components/input/inputNumberMax/index.ts b/src/components/input/inputNumberMax/index.ts new file mode 100644 index 000000000..822b1aba9 --- /dev/null +++ b/src/components/input/inputNumberMax/index.ts @@ -0,0 +1 @@ +export { InputNumberMax, type IInputNumberMaxProps } from './inputNumberMax'; diff --git a/src/components/input/inputNumberMax/inputNumberMax.stories.tsx b/src/components/input/inputNumberMax/inputNumberMax.stories.tsx new file mode 100644 index 000000000..b9879a7ba --- /dev/null +++ b/src/components/input/inputNumberMax/inputNumberMax.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { InputNumberMax, type IInputNumberMaxProps } from './inputNumberMax'; + +const meta: Meta = { + 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; + +/** + * Default usage example of the InputNumberMax component. + */ +export const Default: Story = { + args: { + placeholder: 'Placeholder', + max: 54120, + }, +}; + +const ControlledComponent = (props: IInputNumberMaxProps) => { + const [value, setValue] = useState(); + + return ; +}; + +/** + * Usage example of a controlled InputNumberMax component. + */ +export const Controlled: Story = { + render: ({ onChange, ...props }) => , + args: { + placeholder: 'Controlled input', + max: 120500500.05, + }, +}; + +export default meta; diff --git a/src/components/input/inputNumberMax/inputNumberMax.test.tsx b/src/components/input/inputNumberMax/inputNumberMax.test.tsx new file mode 100644 index 000000000..8865710f2 --- /dev/null +++ b/src/components/input/inputNumberMax/inputNumberMax.test.tsx @@ -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(' 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) => { + const completeProps: IInputNumberMaxProps = { + max: 100, + ...props, + }; + + return ; + }; + + 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(); + }); +}); diff --git a/src/components/input/inputNumberMax/inputNumberMax.tsx b/src/components/input/inputNumberMax/inputNumberMax.tsx new file mode 100644 index 000000000..8fc7397dd --- /dev/null +++ b/src/components/input/inputNumberMax/inputNumberMax.tsx @@ -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 { + /** + * Maximum number set on max button click. + */ + max: number; + /** + * @see IUseNumberMaskProps['onChange'] + */ + onChange?: IUseNumberMaskProps['onChange']; +} + +export const InputNumberMax: React.FC = (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 ( + + + {!disabled && ( + + )} + + ); +}; diff --git a/src/components/input/inputSearch/inputSearch.tsx b/src/components/input/inputSearch/inputSearch.tsx index 182ced9e2..584fcf6ec 100644 --- a/src/components/input/inputSearch/inputSearch.tsx +++ b/src/components/input/inputSearch/inputSearch.tsx @@ -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 { /** diff --git a/src/components/input/inputText/inputText.tsx b/src/components/input/inputText/inputText.tsx index 4fc0bad5d..5e0332fab 100644 --- a/src/components/input/inputText/inputText.tsx +++ b/src/components/input/inputText/inputText.tsx @@ -1,5 +1,5 @@ +import { useInputProps } from '../hooks'; import { InputContainer, type IInputComponentProps } from '../inputContainer'; -import { useInputProps } from '../useInputProps'; export interface IInputTextProps extends IInputComponentProps {} diff --git a/tailwind.config.js b/tailwind.config.js index d1c467b7d..ac70c7bfa 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -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']); }), ], }; diff --git a/yarn.lock b/yarn.lock index 9f555d8a2..a340c03cb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1052,6 +1052,14 @@ resolved "https://registry.yarnpkg.com/@babel/regjsgen/-/regjsgen-0.8.0.tgz#f0ba69b075e1f05fb2825b7fad991e7adbb18310" integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA== +"@babel/runtime-corejs3@^7.23.6": + version "7.23.8" + resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.23.8.tgz#b8aa3d47570bdd08fed77fdfd69542118af0df26" + integrity sha512-2ZzmcDugdm0/YQKFVYsXiwUN7USPX8PM7cytpb4PFl87fM+qYPSvTZX//8tyeJB1j0YDmafBJEbl5f8NfLyuKw== + dependencies: + core-js-pure "^3.30.2" + regenerator-runtime "^0.14.0" + "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.17.8", "@babel/runtime@^7.23.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2": version "7.23.8" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.8.tgz#8ee6fe1ac47add7122902f257b8ddf55c898f650" @@ -4884,6 +4892,11 @@ core-js-pure@^3.23.3: resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.32.2.tgz#b7dbdac528625cf87eb0523b532eb61551b9a6d1" integrity sha512-Y2rxThOuNywTjnX/PgA5vWM6CZ9QB9sz9oGeCixV8MqXZO70z/5SHzf9EeBrEBK0PN36DnEBBu9O/aGWzKuMZQ== +core-js-pure@^3.30.2: + version "3.35.0" + resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.35.0.tgz#4660033304a050215ae82e476bd2513a419fbb34" + integrity sha512-f+eRYmkou59uh7BPcyJ8MC76DiGhspj1KMxVIcF24tzP8NA9HVa1uC7BTW2tgx7E1QVCzDzsgp7kArrzhlz8Ew== + core-util-is@~1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" @@ -6824,6 +6837,13 @@ ignore@^5.2.0, ignore@^5.2.4: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324" integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ== +imask@^7.3.0: + version "7.3.0" + resolved "https://registry.yarnpkg.com/imask/-/imask-7.3.0.tgz#2b851ae8dc517f92cdd0d6dea0447bec9f27731d" + integrity sha512-TG+/rfb62JaQDM2KVrzEHMb4lv2srbsby7vHndXhqgQFB1MgPIXl60VQUfly/Xv5iWfA9ytB+rfQ+skUgINw7A== + dependencies: + "@babel/runtime-corejs3" "^7.23.6" + import-cwd@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-3.0.0.tgz#20845547718015126ea9b3676b7592fb8bd4cf92" @@ -9518,6 +9538,14 @@ react-element-to-jsx-string@^15.0.0: is-plain-object "5.0.0" react-is "18.1.0" +react-imask@^7.3.0: + version "7.3.0" + resolved "https://registry.yarnpkg.com/react-imask/-/react-imask-7.3.0.tgz#c9c9dd67a1e3f49f6b7261ff20ef50b18c0381a7" + integrity sha512-AHoQUeXil6PfqDzJHN08hO2liWxNDRJosNUa2XSqliFY2tXGL/3Elm0msupDNAyNPItAnyF9G5FGFoCfiCn+AQ== + dependencies: + imask "^7.3.0" + prop-types "^15.8.1" + react-is@18.1.0: version "18.1.0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.1.0.tgz#61aaed3096d30eacf2a2127118b5b41387d32a67"