From 61beabed2a7957e7973029a6e399c7c5601f8824 Mon Sep 17 00:00:00 2001 From: Fabrice Francois Date: Mon, 29 Jan 2024 09:24:34 -0500 Subject: [PATCH 01/11] implement input number --- src/components/input/inputNumber/index.ts | 0 .../input/inputNumber/inputNumber.stories.tsx | 42 ++++ .../input/inputNumber/inputNumber.tsx | 205 ++++++++++++++++++ 3 files changed, 247 insertions(+) create mode 100644 src/components/input/inputNumber/index.ts create mode 100644 src/components/input/inputNumber/inputNumber.stories.tsx create mode 100644 src/components/input/inputNumber/inputNumber.tsx diff --git a/src/components/input/inputNumber/index.ts b/src/components/input/inputNumber/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/components/input/inputNumber/inputNumber.stories.tsx b/src/components/input/inputNumber/inputNumber.stories.tsx new file mode 100644 index 000000000..c8cebdbf4 --- /dev/null +++ b/src/components/input/inputNumber/inputNumber.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { InputNumber, type IInputNumberProps } from './inputNumber'; + +const meta: Meta = { + 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=10080-1869&mode=design&t=DMhjcmSjhuHsGH3N-0', + }, + }, +}; + +type Story = StoryObj; + +/** + * Default usage example of the `InputNumber` component. + */ +export const Default: Story = { + args: { + placeholder: '0', + }, +}; + +const ControlledComponent = (props: IInputNumberProps) => { + const [value, setValue] = useState(''); + + return ; +}; + +/** + * Usage example of a controlled `InputNumber` component. + */ +export const Controlled: Story = { + args: { suffix: '%' }, + render: (props) => , +}; + +export default meta; diff --git a/src/components/input/inputNumber/inputNumber.tsx b/src/components/input/inputNumber/inputNumber.tsx new file mode 100644 index 000000000..daa765f10 --- /dev/null +++ b/src/components/input/inputNumber/inputNumber.tsx @@ -0,0 +1,205 @@ +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 { + /** + * Optional string appended to the input value. + */ + suffix?: string; + /** + * Disables the increment spin button + */ + disableIncrement?: boolean; + /** + * Disables the decrement spin button + */ + disableDecrement?: boolean; + /** + * @see IUseNumberMaskProps['onChange'] + */ + onChange?: IUseNumberMaskProps['onChange']; +} + +export const InputNumber: React.FC = (props) => { + const { suffix, onChange, disableDecrement, disableIncrement, ...otherProps } = props; + const { containerProps, inputProps } = useInputProps(otherProps); + + const { className: containerClassName, isDisabled, ...otherContainerProps } = containerProps; + const { + max, + min, + step, + value, + onBlur, + onFocus, + onKeyDown, + className: inputClassName, + ...otherInputProps + } = inputProps; + + const [isFocused, setIsFocused] = useState(false); + const { + ref: numberMaskRef, + value: maskedValue, + unmaskedValue, + } = useNumberMask({ + min, + max, + value, + onChange, + }); + + const suffixedValue = maskedValue && suffix ? maskedValue + suffix : maskedValue; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowUp') { + handleIncrement(); + } else if (e.key === 'ArrowDown') { + handleDecrement(); + } + + onKeyDown?.(e); + }; + + const handleFocus = (e: React.FocusEvent) => { + setIsFocused(true); + onFocus?.(e); + }; + + const handleBlur = (e: React.FocusEvent) => { + setIsFocused(false); + onBlur?.(e); + }; + + const handleIncrement = () => { + const { parsedMax, parsedMin, parsedStep, parsedValue } = parseInputs(unmaskedValue, step, min, max); + + // return the input value if it's bigger than the max + if (parsedValue > parsedMax) { + // Note: dispatching event instead of setting value via imask 'setValue' + // function to avoid noticeable rerender + dispatchChangeEvent(numberMaskRef.current, parsedValue); + } + + // increment value with step + let newValue = parsedValue + parsedStep; + + // round down to the nearest multiple of the step when the value + // is not already a multiple of the step + if (parsedValue % parsedStep !== 0) { + newValue = Math.floor(parsedValue / parsedStep) * parsedStep; + } + + // ensure the new value is within the min and max range + newValue = Math.max(parsedMin, Math.min(parsedMax, newValue)); + dispatchChangeEvent(numberMaskRef.current, newValue); + }; + + const handleDecrement = () => { + const { parsedMax, parsedMin, parsedStep, parsedValue } = parseInputs(unmaskedValue, step, min, max); + + // if the current value is less than the min, don't decrement + if (parsedValue < parsedMin) { + dispatchChangeEvent(numberMaskRef.current, parsedValue); + } + + // decrement value by the step + let newValue = parsedValue - parsedStep; + + // if the value is not a multiple of the step, + // decrement to the biggest multiple of the step that is less than the value + if (parsedValue % parsedStep !== 0) { + newValue = Math.floor(parsedValue / parsedStep) * parsedStep; + } + + // ensure the new value is within the min and max range + newValue = Math.max(parsedMin, Math.min(parsedMax, newValue)); + dispatchChangeEvent(numberMaskRef.current, newValue); + }; + + return ( + + {!isDisabled && ( +