From cb8d712e1e3f1c930bd6626146c8c1a105f31f45 Mon Sep 17 00:00:00 2001 From: Yoann Fleury Date: Wed, 24 Jul 2024 18:43:02 +0200 Subject: [PATCH 1/7] feat: input number --- src/components/Form/FieldNumber/index.tsx | 0 src/components/InputNumber/docs.stories.tsx | 100 +++++++++++++++ src/components/InputNumber/index.tsx | 133 ++++++++++++++++++++ 3 files changed, 233 insertions(+) create mode 100644 src/components/Form/FieldNumber/index.tsx create mode 100644 src/components/InputNumber/docs.stories.tsx create mode 100644 src/components/InputNumber/index.tsx diff --git a/src/components/Form/FieldNumber/index.tsx b/src/components/Form/FieldNumber/index.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/src/components/InputNumber/docs.stories.tsx b/src/components/InputNumber/docs.stories.tsx new file mode 100644 index 000000000..a0fb6bcf3 --- /dev/null +++ b/src/components/InputNumber/docs.stories.tsx @@ -0,0 +1,100 @@ +import React, { useState } from 'react'; + +import { Code, Stack } from '@chakra-ui/react'; + +import { InputNumber } from '.'; + +export default { + title: 'Components/InputNumber', +}; + +export const Default = () => { + const [value, setValue] = useState(201912.12); + return ( + + + {value} + + ); +}; + +export const Suffix = () => ( + + + + +); + +export const Precision = () => ( + + + +); + +export const Currency = () => { + const [value, setValue] = useState(201912.12); + return ( + + setValue(v)} currency="EUR" /> + setValue(v)} currency="USD" /> + setValue(v)} currency="GBP" /> + {value} + + ); +}; + +export const LocaleFR = () => { + const [value, setValue] = useState(201912.12); + return ( + + setValue(v)} locale="fr" /> + setValue(v)} + locale="fr" + currency="EUR" + /> + setValue(v)} + locale="fr" + currency="USD" + /> + setValue(v)} + locale="fr" + currency="GBP" + /> + {value} + + ); +}; + +export const Placeholder = () => ( + + + + + + +); + +export const FixedDecimalScale = () => { + return ( + + + + + ); +}; + +// export const WithMinMax = () => { +// const [value, setValue] = useState(3); +// return ( +// +// +// {value} +// +// ); +// }; diff --git a/src/components/InputNumber/index.tsx b/src/components/InputNumber/index.tsx new file mode 100644 index 000000000..749eb11d3 --- /dev/null +++ b/src/components/InputNumber/index.tsx @@ -0,0 +1,133 @@ +import React, { ComponentProps, useRef, useState } from 'react'; + +import { Input, InputProps, forwardRef } from '@chakra-ui/react'; +import { useTranslation } from 'react-i18next'; +import { NumericFormat, numericFormatter } from 'react-number-format'; + +import { getNumberFormatInfo } from '@/lib/numbers'; + +type CustomProps = { + value?: number | null; + defaultValue?: number | null; + /** + * Provide a number and the placeholder will also display the currency format, + * prefix and suffix. + * Provide a string and the placeholder will display only the placeholder + */ + placeholder?: string | number; + locale?: string; + /** + * Intl.NumberFormat options that will put the style as currency and set the + * currency code to the given value. + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat + * @see https://en.wikipedia.org/wiki/ISO_4217#List_of_ISO_4217_currency_codes + */ + currency?: string | null; + /** Will be displayed before the value (and placeholder if placeholder is a number) in the input */ + prefix?: string; + /** Will be displayed after the value (and placeholder if placeholder is a number) in the input */ + suffix?: string; + /** + * The number of decimal points used to round the value + */ + precision?: number; + fixedDecimals?: boolean; + onChange?(value: number | null): void; +}; + +export type InputNumberProps = Overwrite; + +export const InputNumber = forwardRef( + ( + { + value, + defaultValue, + locale, + currency = null, + prefix = '', + suffix = '', + precision = 0, + fixedDecimals = true, + onChange = () => undefined, + placeholder, + ...rest + }, + ref + ) => { + const { i18n } = useTranslation(); + const { + decimalsSeparator, + groupSeparator, + currencyPrefix, + currencySuffix, + } = getNumberFormatInfo({ + locale: locale ?? i18n.language, + currency, + }); + + const [internalValue, setInternalValue] = useState( + value ?? defaultValue ?? null + ); + const [isFocused, setIsFocused] = useState(false); + const tmpValueRef = useRef(internalValue); + + const updateValue = (v: number | null) => { + setInternalValue(v); + onChange(v); + }; + + const getNumericFormatOptions = () => + ({ + getInputRef: ref, + decimalScale: precision, + fixedDecimalScale: !isFocused ? fixedDecimals : false, + decimalSeparator: decimalsSeparator ?? '.', + thousandSeparator: groupSeparator ?? ',', + suffix: `${currencySuffix}${suffix}`, + prefix: `${currencyPrefix}${prefix}`, + onValueChange: (values) => { + tmpValueRef.current = values.floatValue ?? null; + + // Prevent -0 to be replaced with 0 when input is controlled + if (values.floatValue === 0) return; + + updateValue(values.floatValue ?? null); + }, + }) satisfies ComponentProps; + + return ( + { + setIsFocused(true); + rest.onFocus?.(e); + }} + onBlur={(e) => { + setIsFocused(false); + updateValue(tmpValueRef.current); + rest.onBlur?.(e); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + updateValue(tmpValueRef.current); + } + rest.onKeyDown?.(e); + }} + /> + ); + } +); From da97d4ac259ed6ea45d4a63913e153496ae2aa18 Mon Sep 17 00:00:00 2001 From: Yoann Fleury Date: Wed, 31 Jul 2024 09:28:18 +0200 Subject: [PATCH 2/7] new implementation --- src/components/InputNumber/docs.stories.tsx | 18 +++--- src/components/InputNumber/index.tsx | 70 +++++++++++++-------- 2 files changed, 52 insertions(+), 36 deletions(-) diff --git a/src/components/InputNumber/docs.stories.tsx b/src/components/InputNumber/docs.stories.tsx index a0fb6bcf3..18753901d 100644 --- a/src/components/InputNumber/docs.stories.tsx +++ b/src/components/InputNumber/docs.stories.tsx @@ -89,12 +89,12 @@ export const FixedDecimalScale = () => { ); }; -// export const WithMinMax = () => { -// const [value, setValue] = useState(3); -// return ( -// -// -// {value} -// -// ); -// }; +export const WithMinMax = () => { + const [value, setValue] = useState(3); + return ( + + + {value} + + ); +}; diff --git a/src/components/InputNumber/index.tsx b/src/components/InputNumber/index.tsx index 749eb11d3..49c13f992 100644 --- a/src/components/InputNumber/index.tsx +++ b/src/components/InputNumber/index.tsx @@ -1,6 +1,12 @@ -import React, { ComponentProps, useRef, useState } from 'react'; +import React, { ChangeEvent, ComponentProps, useRef, useState } from 'react'; -import { Input, InputProps, forwardRef } from '@chakra-ui/react'; +import { + Input, + InputProps, + UseNumberInputProps, + forwardRef, + useNumberInput, +} from '@chakra-ui/react'; import { useTranslation } from 'react-i18next'; import { NumericFormat, numericFormatter } from 'react-number-format'; @@ -36,7 +42,7 @@ type CustomProps = { onChange?(value: number | null): void; }; -export type InputNumberProps = Overwrite; +export type InputNumberProps = Overwrite; export const InputNumber = forwardRef( ( @@ -66,17 +72,17 @@ export const InputNumber = forwardRef( currency, }); + const updateValue = (v: number | null) => { + setInternalValue(v); + onChange(v); + }; + const [internalValue, setInternalValue] = useState( value ?? defaultValue ?? null ); const [isFocused, setIsFocused] = useState(false); const tmpValueRef = useRef(internalValue); - const updateValue = (v: number | null) => { - setInternalValue(v); - onChange(v); - }; - const getNumericFormatOptions = () => ({ getInputRef: ref, @@ -96,14 +102,39 @@ export const InputNumber = forwardRef( }, }) satisfies ComponentProps; + const { getInputProps, getIncrementButtonProps, getDecrementButtonProps } = + useNumberInput({ + defaultValue: defaultValue ?? undefined, + // TODO NaN quand vide + value: value === undefined ? undefined : value ?? undefined, + onChange: (_, valueAsNumber) => updateValue(valueAsNumber), + ...rest, + ...getNumericFormatOptions(), + + onFocus: (e) => { + setIsFocused(true); + rest.onFocus?.(e); + }, + onBlur: (e) => { + setIsFocused(false); + updateValue(tmpValueRef.current); + rest.onBlur?.(e); + }, + // onKeyDown: (e) => { + // if (e.key === 'Enter') { + // updateValue(tmpValueRef.current); + // } + // rest.onKeyDown?.(e); + // }, + }); + + const inputProps = getInputProps(); + return ( ( }) : placeholder } - onFocus={(e) => { - setIsFocused(true); - rest.onFocus?.(e); - }} - onBlur={(e) => { - setIsFocused(false); - updateValue(tmpValueRef.current); - rest.onBlur?.(e); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - updateValue(tmpValueRef.current); - } - rest.onKeyDown?.(e); - }} /> ); } From 57647ccce1d4047bc87591df251100e50751be9d Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Wed, 31 Jul 2024 10:10:55 +0200 Subject: [PATCH 3/7] wip: InputNumber with keyboard --- src/components/InputNumber/docs.stories.tsx | 23 +- src/components/InputNumber/index.tsx | 225 +++++++++++++------- src/theme/components/button.ts | 3 + 3 files changed, 171 insertions(+), 80 deletions(-) diff --git a/src/components/InputNumber/docs.stories.tsx b/src/components/InputNumber/docs.stories.tsx index 18753901d..1a08fe500 100644 --- a/src/components/InputNumber/docs.stories.tsx +++ b/src/components/InputNumber/docs.stories.tsx @@ -13,6 +13,8 @@ export const Default = () => { return ( + + {value} ); @@ -27,7 +29,7 @@ export const Suffix = () => ( export const Precision = () => ( - + ); @@ -74,17 +76,17 @@ export const LocaleFR = () => { export const Placeholder = () => ( - - + + ); export const FixedDecimalScale = () => { return ( - - + + ); }; @@ -98,3 +100,14 @@ export const WithMinMax = () => { ); }; + +export const Step = () => { + const [value, setValue] = useState(0); + return ( + + + + {value} + + ); +}; diff --git a/src/components/InputNumber/index.tsx b/src/components/InputNumber/index.tsx index 49c13f992..538a6205b 100644 --- a/src/components/InputNumber/index.tsx +++ b/src/components/InputNumber/index.tsx @@ -1,62 +1,66 @@ -import React, { ChangeEvent, ComponentProps, useRef, useState } from 'react'; +import React, { ComponentProps, useRef, useState } from 'react'; import { + Button, + ButtonGroup, + Divider, Input, + InputGroup, + InputGroupProps, InputProps, - UseNumberInputProps, forwardRef, - useNumberInput, } from '@chakra-ui/react'; import { useTranslation } from 'react-i18next'; +import { FaCaretDown, FaCaretUp } from 'react-icons/fa'; import { NumericFormat, numericFormatter } from 'react-number-format'; +import { ceil, clamp } from 'remeda'; +import { DEFAULT_LANGUAGE_KEY } from '@/lib/i18n/constants'; import { getNumberFormatInfo } from '@/lib/numbers'; type CustomProps = { value?: number | null; defaultValue?: number | null; - /** - * Provide a number and the placeholder will also display the currency format, - * prefix and suffix. - * Provide a string and the placeholder will display only the placeholder - */ placeholder?: string | number; locale?: string; - /** - * Intl.NumberFormat options that will put the style as currency and set the - * currency code to the given value. - * - * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat - * @see https://en.wikipedia.org/wiki/ISO_4217#List_of_ISO_4217_currency_codes - */ currency?: string | null; - /** Will be displayed before the value (and placeholder if placeholder is a number) in the input */ prefix?: string; - /** Will be displayed after the value (and placeholder if placeholder is a number) in the input */ suffix?: string; - /** - * The number of decimal points used to round the value - */ precision?: number; - fixedDecimals?: boolean; + fixedPrecision?: boolean; + step?: number; + bigStep?: number; + min?: number; + max?: number; + clampValueOnBlur?: boolean; + showButtons?: boolean; onChange?(value: number | null): void; + inputGroupProps?: InputGroupProps; }; -export type InputNumberProps = Overwrite; +export type InputNumberProps = Overwrite; export const InputNumber = forwardRef( ( { + size, value, defaultValue, locale, currency = null, prefix = '', suffix = '', - precision = 0, - fixedDecimals = true, + precision = 2, + step = 1, + bigStep = step * 10, + min, + max, + clampValueOnBlur = true, + fixedPrecision = false, onChange = () => undefined, placeholder, + showButtons = true, + inputGroupProps, ...rest }, ref @@ -68,30 +72,30 @@ export const InputNumber = forwardRef( currencyPrefix, currencySuffix, } = getNumberFormatInfo({ - locale: locale ?? i18n.language, - currency, + locale: locale ?? i18n.language ?? DEFAULT_LANGUAGE_KEY, + currency: currency ?? 'EUR', }); - const updateValue = (v: number | null) => { - setInternalValue(v); - onChange(v); - }; - const [internalValue, setInternalValue] = useState( value ?? defaultValue ?? null ); const [isFocused, setIsFocused] = useState(false); const tmpValueRef = useRef(internalValue); + const updateValue = (v: number | null) => { + setInternalValue(v); + onChange(v); + }; + const getNumericFormatOptions = () => ({ getInputRef: ref, decimalScale: precision, - fixedDecimalScale: !isFocused ? fixedDecimals : false, + fixedDecimalScale: !isFocused ? fixedPrecision : false, decimalSeparator: decimalsSeparator ?? '.', thousandSeparator: groupSeparator ?? ',', - suffix: `${currencySuffix}${suffix}`, - prefix: `${currencyPrefix}${prefix}`, + suffix: `${currency ? currencySuffix : ''}${suffix}`, + prefix: `${currency ? currencyPrefix : ''}${prefix}`, onValueChange: (values) => { tmpValueRef.current = values.floatValue ?? null; @@ -102,48 +106,119 @@ export const InputNumber = forwardRef( }, }) satisfies ComponentProps; - const { getInputProps, getIncrementButtonProps, getDecrementButtonProps } = - useNumberInput({ - defaultValue: defaultValue ?? undefined, - // TODO NaN quand vide - value: value === undefined ? undefined : value ?? undefined, - onChange: (_, valueAsNumber) => updateValue(valueAsNumber), - ...rest, - ...getNumericFormatOptions(), - - onFocus: (e) => { - setIsFocused(true); - rest.onFocus?.(e); - }, - onBlur: (e) => { - setIsFocused(false); - updateValue(tmpValueRef.current); - rest.onBlur?.(e); - }, - // onKeyDown: (e) => { - // if (e.key === 'Enter') { - // updateValue(tmpValueRef.current); - // } - // rest.onKeyDown?.(e); - // }, - }); - - const inputProps = getInputProps(); - return ( - + + { + setIsFocused(true); + rest.onFocus?.(e); + }} + onBlur={(e) => { + setIsFocused(false); + const v = tmpValueRef.current; + updateValue( + clampValueOnBlur + ? v === null + ? null + : clamp(v, { min, max }) + : tmpValueRef.current + ); + rest.onBlur?.(e); + }} + onKeyDown={(e) => { + const v = tmpValueRef.current; + + if (e.key === 'Enter') { + updateValue(v); + } + if (e.key === 'ArrowUp') { + updateValue( + clamp((v ?? 0) + (e.shiftKey ? bigStep : step), { min, max }) + ); + } + if (e.key === 'ArrowDown') { + updateValue( + clamp((v ?? 0) - (e.shiftKey ? bigStep : step), { min, max }) + ); + } + rest.onKeyDown?.(e); + }} + /> + {showButtons && ( + + + + + + )} + ); } ); diff --git a/src/theme/components/button.ts b/src/theme/components/button.ts index b8a0d62ac..38a188cb7 100644 --- a/src/theme/components/button.ts +++ b/src/theme/components/button.ts @@ -115,6 +115,9 @@ export const buttonTheme = defineStyleConfig({ link: { boxShadow: 'none', }, + unstyled: { + boxShadow: 'none', + }, solid: (props) => props.colorScheme === 'gray' ? variantSecondary(props) : {}, outline: variantSecondary, From 7e0b2b274794e232767064ab2e1239bc45a82126 Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Wed, 31 Jul 2024 11:26:02 +0200 Subject: [PATCH 4/7] feat: field number --- src/components/Form/FieldCurrency/index.tsx | 111 ---------------- .../FieldNumber.spec.tsx} | 20 +-- .../docs.stories.tsx | 20 +-- src/components/Form/FieldNumber/index.tsx | 118 ++++++++++++++++++ src/components/Form/FieldText/index.tsx | 2 +- src/components/Form/FormFieldController.tsx | 9 +- src/components/InputCurrency/docs.stories.tsx | 94 -------------- src/components/InputCurrency/index.tsx | 116 ----------------- src/components/InputNumber/docs.stories.tsx | 110 ++++++++++++---- src/components/InputNumber/index.tsx | 49 ++++---- 10 files changed, 254 insertions(+), 395 deletions(-) delete mode 100644 src/components/Form/FieldCurrency/index.tsx rename src/components/Form/{FieldCurrency/FieldCurrency.spec.tsx => FieldNumber/FieldNumber.spec.tsx} (93%) rename src/components/Form/{FieldCurrency => FieldNumber}/docs.stories.tsx (94%) delete mode 100644 src/components/InputCurrency/docs.stories.tsx delete mode 100644 src/components/InputCurrency/index.tsx diff --git a/src/components/Form/FieldCurrency/index.tsx b/src/components/Form/FieldCurrency/index.tsx deleted file mode 100644 index 5a755e7e6..000000000 --- a/src/components/Form/FieldCurrency/index.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { ReactNode } from 'react'; - -import { - Flex, - FlexProps, - InputGroup, - InputLeftElement, - InputRightElement, -} from '@chakra-ui/react'; -import { - Controller, - ControllerRenderProps, - FieldPath, - FieldValues, -} from 'react-hook-form'; - -import { FieldCommonProps } from '@/components/Form/FormFieldController'; -import { FormFieldError } from '@/components/Form/FormFieldError'; -import { InputCurrency, InputCurrencyProps } from '@/components/InputCurrency'; - -type InputCurrencyRootProps = Pick< - InputCurrencyProps, - | 'placeholder' - | 'size' - | 'autoFocus' - | 'locale' - | 'currency' - | 'decimals' - | 'fixedDecimals' - | 'prefix' - | 'suffix' ->; - -export type FieldCurrencyProps< - TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath, -> = { - type: 'currency'; - inCents?: boolean; - startElement?: ReactNode; - endElement?: ReactNode; - inputCurrencyProps?: RemoveFromType< - RemoveFromType, - ControllerRenderProps - >; - containerProps?: FlexProps; -} & InputCurrencyRootProps & - FieldCommonProps; - -export const FieldCurrency = < - TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath, ->( - props: FieldCurrencyProps -) => { - const formatValue = ( - value: number | undefined | null, - type: 'to-cents' | 'from-cents' - ) => { - if (value === undefined || value === null) return value; - if (props.inCents !== true) return value; - if (type === 'to-cents') return Math.round(value * 100); - if (type === 'from-cents') return value / 100; - }; - - return ( - ( - - - field.onChange(formatValue(v, 'to-cents'))} - /> - {!!props.startElement && ( - - {props.startElement} - - )} - {!!props.endElement && ( - - {props.endElement} - - )} - - - - )} - /> - ); -}; diff --git a/src/components/Form/FieldCurrency/FieldCurrency.spec.tsx b/src/components/Form/FieldNumber/FieldNumber.spec.tsx similarity index 93% rename from src/components/Form/FieldCurrency/FieldCurrency.spec.tsx rename to src/components/Form/FieldNumber/FieldNumber.spec.tsx index 39a0f09e2..5d41d74ad 100644 --- a/src/components/Form/FieldCurrency/FieldCurrency.spec.tsx +++ b/src/components/Form/FieldNumber/FieldNumber.spec.tsx @@ -20,9 +20,10 @@ test('update value', async () => { Balance )} @@ -49,9 +50,10 @@ test('update value in cents', async () => { Balance @@ -79,9 +81,10 @@ test('update value locale fr', async () => { Balance @@ -89,7 +92,7 @@ test('update value locale fr', async () => { ); const input = screen.getByLabelText('Balance'); - await user.type(input, '12.00'); + await user.type(input, '12,00'); expect(input.value).toBe('12,00 €'); await user.click(screen.getByRole('button', { name: 'Submit' })); expect(mockedSubmit).toHaveBeenCalledWith({ balance: 12 }); @@ -109,10 +112,11 @@ test('update value no decimals', async () => { Balance )} @@ -140,9 +144,11 @@ test('default value', async () => { Balance )} diff --git a/src/components/Form/FieldCurrency/docs.stories.tsx b/src/components/Form/FieldNumber/docs.stories.tsx similarity index 94% rename from src/components/Form/FieldCurrency/docs.stories.tsx rename to src/components/Form/FieldNumber/docs.stories.tsx index 78e0e6e46..d53b6f1f5 100644 --- a/src/components/Form/FieldCurrency/docs.stories.tsx +++ b/src/components/Form/FieldNumber/docs.stories.tsx @@ -9,7 +9,7 @@ import { Icon } from '@/components/Icons'; import { Form, FormField, FormFieldController, FormFieldLabel } from '../'; export default { - title: 'Form/FieldCurrency', + title: 'Form/FieldNumber', }; type FormSchema = z.infer>; @@ -32,7 +32,7 @@ export const Default = () => { Balance { Balance { Balance { Balance @@ -135,7 +135,7 @@ export const Disabled = () => { Balance { Balance { Balance diff --git a/src/components/Form/FieldNumber/index.tsx b/src/components/Form/FieldNumber/index.tsx index e69de29bb..0aa87d7e5 100644 --- a/src/components/Form/FieldNumber/index.tsx +++ b/src/components/Form/FieldNumber/index.tsx @@ -0,0 +1,118 @@ +import { ReactNode } from 'react'; + +import { + Flex, + FlexProps, + InputGroup, + InputLeftElement, + InputRightElement, +} from '@chakra-ui/react'; +import { + Controller, + ControllerRenderProps, + FieldPath, + FieldValues, +} from 'react-hook-form'; + +import { FieldCommonProps } from '@/components/Form/FormFieldController'; +import { FormFieldError } from '@/components/Form/FormFieldError'; +import { InputNumber, InputNumberProps } from '@/components/InputNumber'; + +type InputNumberRootProps = Pick< + InputNumberProps, + | 'placeholder' + | 'size' + | 'autoFocus' + | 'locale' + | 'currency' + | 'precision' + | 'fixedPrecision' + | 'prefix' + | 'suffix' + | 'min' + | 'max' + | 'step' + | 'bigStep' +>; + +export type FieldNumberProps< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + type: 'number'; + inCents?: boolean; + startElement?: ReactNode; + endElement?: ReactNode; + inputNumberProps?: RemoveFromType< + RemoveFromType, + ControllerRenderProps + >; + containerProps?: FlexProps; +} & InputNumberRootProps & + FieldCommonProps; + +export const FieldNumber = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>( + props: FieldNumberProps +) => { + const formatValue = ( + value: number | undefined | null, + type: 'to-cents' | 'from-cents' + ) => { + if (value === undefined || value === null) return null; + if (props.inCents !== true) return value ?? null; + if (type === 'to-cents') return Math.round(value * 100); + if (type === 'from-cents') return value / 100; + return null; + }; + + return ( + ( + + + field.onChange(formatValue(v, 'to-cents'))} + /> + {!!props.startElement && ( + + {props.startElement} + + )} + {!!props.endElement && ( + + {props.endElement} + + )} + + + + )} + /> + ); +}; diff --git a/src/components/Form/FieldText/index.tsx b/src/components/Form/FieldText/index.tsx index 93374e9c5..8f3c498ab 100644 --- a/src/components/Form/FieldText/index.tsx +++ b/src/components/Form/FieldText/index.tsx @@ -26,7 +26,7 @@ export type FieldTextProps< TFieldValues extends FieldValues = FieldValues, TName extends FieldPath = FieldPath, > = { - type: 'text' | 'email' | 'number' | 'tel'; + type: 'text' | 'email' | 'tel'; startElement?: ReactNode; endElement?: ReactNode; inputProps?: RemoveFromType< diff --git a/src/components/Form/FormFieldController.tsx b/src/components/Form/FormFieldController.tsx index 4ade904f7..03d15cedb 100644 --- a/src/components/Form/FormFieldController.tsx +++ b/src/components/Form/FormFieldController.tsx @@ -11,9 +11,9 @@ import { useFormField } from '@/components/Form/FormField'; import { FieldCheckbox, FieldCheckboxProps } from './FieldCheckbox'; import { FieldCheckboxes, FieldCheckboxesProps } from './FieldCheckboxes'; -import { FieldCurrency, FieldCurrencyProps } from './FieldCurrency'; import { FieldDate, FieldDateProps } from './FieldDate'; import { FieldMultiSelect, FieldMultiSelectProps } from './FieldMultiSelect'; +import { FieldNumber, FieldNumberProps } from './FieldNumber'; import { FieldOtp, FieldOtpProps } from './FieldOtp'; import { FieldPassword, FieldPasswordProps } from './FieldPassword'; import { FieldRadios, FieldRadiosProps } from './FieldRadios'; @@ -55,7 +55,7 @@ export type FormFieldControllerProps< | FieldMultiSelectProps | FieldOtpProps | FieldDateProps - | FieldCurrencyProps + | FieldNumberProps | FieldPasswordProps | FieldCheckboxesProps | FieldRadiosProps; @@ -80,15 +80,14 @@ export const FormFieldController = < case 'text': case 'email': - case 'number': case 'tel': return ; case 'password': return ; - case 'currency': - return ; + case 'number': + return ; case 'textarea': return ; diff --git a/src/components/InputCurrency/docs.stories.tsx b/src/components/InputCurrency/docs.stories.tsx deleted file mode 100644 index 2c97983fb..000000000 --- a/src/components/InputCurrency/docs.stories.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import React, { useState } from 'react'; - -import { Code, Stack } from '@chakra-ui/react'; - -import { InputCurrency } from '.'; - -export default { - title: 'Components/InputCurrency', -}; - -export const Default = () => { - const [value, setValue] = useState(1020.2); - return ( - - - - {value} - - ); -}; - -export const Currency = () => { - const [value, setValue] = useState(1020.2); - return ( - - setValue(v)} - currency="EUR" - /> - setValue(v)} - currency="USD" - /> - setValue(v)} - currency="GBP" - /> - {value} - - ); -}; - -export const LocaleFR = () => { - const [value, setValue] = useState(1020.2); - return ( - - setValue(v)} locale="fr" /> - setValue(v)} - locale="fr" - currency="EUR" - /> - setValue(v)} - locale="fr" - currency="USD" - /> - setValue(v)} - locale="fr" - currency="GBP" - /> - {value} - - ); -}; - -export const DefaultValue = () => ( - - - - - -); - -export const Placeholder = () => ( - - - - - -); - -export const Suffix = () => ( - - - -); diff --git a/src/components/InputCurrency/index.tsx b/src/components/InputCurrency/index.tsx deleted file mode 100644 index 4608baa83..000000000 --- a/src/components/InputCurrency/index.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import React, { ComponentProps, useRef, useState } from 'react'; - -import { Input, InputProps, forwardRef } from '@chakra-ui/react'; -import { useTranslation } from 'react-i18next'; -import { NumericFormat, numericFormatter } from 'react-number-format'; - -import { getNumberFormatInfo } from '@/lib/numbers'; - -type CustomProps = { - value?: number | null; - defaultValue?: number | null; - placeholder?: string | number; - locale?: string; - currency?: string | null; - prefix?: string; - suffix?: string; - decimals?: number; - fixedDecimals?: boolean; - onChange?(value: number | null): void; -}; - -export type InputCurrencyProps = Overwrite; - -export const InputCurrency = forwardRef( - ( - { - value, - defaultValue, - locale, - currency = 'EUR', - prefix = '', - suffix = '', - decimals = 2, - fixedDecimals = true, - onChange = () => undefined, - placeholder, - ...rest - }, - ref - ) => { - const { i18n } = useTranslation(); - const { - decimalsSeparator, - groupSeparator, - currencyPrefix, - currencySuffix, - } = getNumberFormatInfo({ - locale: locale ?? i18n.language, - currency, - }); - - const [internalValue, setInternalValue] = useState( - value ?? defaultValue ?? null - ); - const [isFocused, setIsFocused] = useState(false); - const tmpValueRef = useRef(internalValue); - - const updateValue = (v: number | null) => { - setInternalValue(v); - onChange(v); - }; - - const getNumericFormatOptions = () => - ({ - getInputRef: ref, - decimalScale: decimals, - fixedDecimalScale: !isFocused ? fixedDecimals : false, - decimalSeparator: decimalsSeparator ?? '.', - thousandSeparator: groupSeparator ?? ',', - suffix: `${currencySuffix}${suffix}`, - prefix: `${currencyPrefix}${prefix}`, - onValueChange: (values) => { - tmpValueRef.current = values.floatValue ?? null; - - // Prevent -0 to be replaced with 0 when input is controlled - if (values.floatValue === 0) return; - - updateValue(values.floatValue ?? null); - }, - }) satisfies ComponentProps; - - return ( - { - setIsFocused(true); - rest.onFocus?.(e); - }} - onBlur={(e) => { - setIsFocused(false); - updateValue(tmpValueRef.current); - rest.onBlur?.(e); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - updateValue(tmpValueRef.current); - } - rest.onKeyDown?.(e); - }} - /> - ); - } -); diff --git a/src/components/InputNumber/docs.stories.tsx b/src/components/InputNumber/docs.stories.tsx index 1a08fe500..f269d9815 100644 --- a/src/components/InputNumber/docs.stories.tsx +++ b/src/components/InputNumber/docs.stories.tsx @@ -20,18 +20,60 @@ export const Default = () => { ); }; -export const Suffix = () => ( - - - - -); +export const Suffix = () => { + const [value, setValue] = useState(null); + return ( + + + + + ); +}; -export const Precision = () => ( - - - -); +export const Precision = () => { + const [value, setValue] = useState(null); + return ( + + + + ); +}; + +export const FixedPrecision = () => { + const [value, setValue] = useState(201912.1); + return ( + + + + + ); +}; export const Currency = () => { const [value, setValue] = useState(201912.12); @@ -73,20 +115,30 @@ export const LocaleFR = () => { ); }; -export const Placeholder = () => ( - - - - - - -); - -export const FixedDecimalScale = () => { +export const Placeholder = () => { + const [value, setValue] = useState(null); return ( - - + + + + ); }; @@ -111,3 +163,15 @@ export const Step = () => { ); }; + +export const ShowButtons = () => { + const [value, setValue] = useState(0); + return ( + + + + + {value} + + ); +}; diff --git a/src/components/InputNumber/index.tsx b/src/components/InputNumber/index.tsx index 538a6205b..99a832d25 100644 --- a/src/components/InputNumber/index.tsx +++ b/src/components/InputNumber/index.tsx @@ -1,4 +1,4 @@ -import React, { ComponentProps, useRef, useState } from 'react'; +import React, { ComponentProps, useEffect, useRef, useState } from 'react'; import { Button, @@ -13,14 +13,15 @@ import { import { useTranslation } from 'react-i18next'; import { FaCaretDown, FaCaretUp } from 'react-icons/fa'; import { NumericFormat, numericFormatter } from 'react-number-format'; -import { ceil, clamp } from 'remeda'; +import { clamp } from 'remeda'; import { DEFAULT_LANGUAGE_KEY } from '@/lib/i18n/constants'; import { getNumberFormatInfo } from '@/lib/numbers'; -type CustomProps = { - value?: number | null; +export type InputNumberCustomProps = { + value: number | null; defaultValue?: number | null; + onChange(value: number | null): void; placeholder?: string | number; locale?: string; currency?: string | null; @@ -34,11 +35,10 @@ type CustomProps = { max?: number; clampValueOnBlur?: boolean; showButtons?: boolean; - onChange?(value: number | null): void; inputGroupProps?: InputGroupProps; }; -export type InputNumberProps = Overwrite; +export type InputNumberProps = Overwrite; export const InputNumber = forwardRef( ( @@ -59,8 +59,9 @@ export const InputNumber = forwardRef( fixedPrecision = false, onChange = () => undefined, placeholder, - showButtons = true, + showButtons = false, inputGroupProps, + children, ...rest }, ref @@ -75,17 +76,8 @@ export const InputNumber = forwardRef( locale: locale ?? i18n.language ?? DEFAULT_LANGUAGE_KEY, currency: currency ?? 'EUR', }); - - const [internalValue, setInternalValue] = useState( - value ?? defaultValue ?? null - ); const [isFocused, setIsFocused] = useState(false); - const tmpValueRef = useRef(internalValue); - - const updateValue = (v: number | null) => { - setInternalValue(v); - onChange(v); - }; + const tmpValueRef = useRef(value ?? defaultValue ?? null); const getNumericFormatOptions = () => ({ @@ -102,7 +94,7 @@ export const InputNumber = forwardRef( // Prevent -0 to be replaced with 0 when input is controlled if (values.floatValue === 0) return; - updateValue(values.floatValue ?? null); + onChange(values.floatValue ?? null); }, }) satisfies ComponentProps; @@ -114,7 +106,7 @@ export const InputNumber = forwardRef( pe={showButtons ? 8 : undefined} {...rest} {...getNumericFormatOptions()} - value={internalValue === undefined ? undefined : internalValue ?? ''} + value={value === undefined ? undefined : value ?? ''} defaultValue={defaultValue ?? undefined} placeholder={ typeof placeholder === 'number' @@ -131,7 +123,7 @@ export const InputNumber = forwardRef( onBlur={(e) => { setIsFocused(false); const v = tmpValueRef.current; - updateValue( + onChange( clampValueOnBlur ? v === null ? null @@ -144,15 +136,15 @@ export const InputNumber = forwardRef( const v = tmpValueRef.current; if (e.key === 'Enter') { - updateValue(v); + onChange(v); } if (e.key === 'ArrowUp') { - updateValue( + onChange( clamp((v ?? 0) + (e.shiftKey ? bigStep : step), { min, max }) ); } if (e.key === 'ArrowDown') { - updateValue( + onChange( clamp((v ?? 0) - (e.shiftKey ? bigStep : step), { min, max }) ); } @@ -169,7 +161,7 @@ export const InputNumber = forwardRef( isAttached orientation="vertical" variant="unstyled" - size="xs" + size={size === 'lg' ? 'sm' : 'xs'} borderStart="1px solid" borderStartColor="gray.200" _dark={{ @@ -177,9 +169,9 @@ export const InputNumber = forwardRef( }} > )} + {children} ); } From 81574f5a14cc46006d33aa7249c97dfe2c402412 Mon Sep 17 00:00:00 2001 From: Yoann Fleury Date: Fri, 2 Aug 2024 11:12:01 +0200 Subject: [PATCH 5/7] feat: add tests and story --- .../Form/FieldNumber/FieldNumber.spec.tsx | 41 ++++++++++++++++++- .../Form/FieldNumber/docs.stories.tsx | 28 +++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/components/Form/FieldNumber/FieldNumber.spec.tsx b/src/components/Form/FieldNumber/FieldNumber.spec.tsx index 5d41d74ad..91f222d8d 100644 --- a/src/components/Form/FieldNumber/FieldNumber.spec.tsx +++ b/src/components/Form/FieldNumber/FieldNumber.spec.tsx @@ -174,9 +174,10 @@ test('disabled', async () => { Balance @@ -184,7 +185,43 @@ test('disabled', async () => { ); const input = screen.getByLabelText('Balance'); - await user.type(input, '10.00'); + await user.type(input, '42.00'); + expect(input.value).toBe('€12'); await user.click(screen.getByRole('button', { name: 'Submit' })); expect(mockedSubmit).toHaveBeenCalledWith({ balance: 12 }); }); + +test('update value using keyboard step', async () => { + const user = setupUser(); + const mockedSubmit = vi.fn(); + + render( + + {({ form }) => ( + + Balance + + + )} + + ); + const input = screen.getByLabelText('Balance'); + await user.click(input); + await user.keyboard('[ArrowUp][ArrowUp]'); + expect(input.value).toBe('€14'); + await user.click(input); + + await user.click(screen.getByRole('button', { name: 'Submit' })); + expect(mockedSubmit).toHaveBeenCalledWith({ balance: 14 }); +}); diff --git a/src/components/Form/FieldNumber/docs.stories.tsx b/src/components/Form/FieldNumber/docs.stories.tsx index d53b6f1f5..9762b7fa5 100644 --- a/src/components/Form/FieldNumber/docs.stories.tsx +++ b/src/components/Form/FieldNumber/docs.stories.tsx @@ -48,6 +48,34 @@ export const Default = () => { ); }; +export const DefaultValue = () => { + const form = useForm({ + ...formOptions, + defaultValues: { balance: 12 }, + }); + + return ( +
console.log(values)}> + + + Balance + + + + + + +
+ ); +}; + export const InCents = () => { const form = useForm(formOptions); From e636d3e593a122d8ccd0d95029268409183de114 Mon Sep 17 00:00:00 2001 From: Yoann Fleury Date: Fri, 2 Aug 2024 11:19:35 +0200 Subject: [PATCH 6/7] fix: big step and add test --- src/components/Form/FieldNumber/FieldNumber.spec.tsx | 6 ++++-- src/components/Form/FieldNumber/index.tsx | 2 +- src/components/InputNumber/index.tsx | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/Form/FieldNumber/FieldNumber.spec.tsx b/src/components/Form/FieldNumber/FieldNumber.spec.tsx index 91f222d8d..157295094 100644 --- a/src/components/Form/FieldNumber/FieldNumber.spec.tsx +++ b/src/components/Form/FieldNumber/FieldNumber.spec.tsx @@ -191,7 +191,7 @@ test('disabled', async () => { expect(mockedSubmit).toHaveBeenCalledWith({ balance: 12 }); }); -test('update value using keyboard step', async () => { +test('update value using keyboard step and big step', async () => { const user = setupUser(); const mockedSubmit = vi.fn(); @@ -222,6 +222,8 @@ test('update value using keyboard step', async () => { expect(input.value).toBe('€14'); await user.click(input); + await user.keyboard('{Shift>}[ArrowUp][ArrowUp]{/Shift}'); + await user.click(screen.getByRole('button', { name: 'Submit' })); - expect(mockedSubmit).toHaveBeenCalledWith({ balance: 14 }); + expect(mockedSubmit).toHaveBeenCalledWith({ balance: 34 }); }); diff --git a/src/components/Form/FieldNumber/index.tsx b/src/components/Form/FieldNumber/index.tsx index 0aa87d7e5..ecd4b2756 100644 --- a/src/components/Form/FieldNumber/index.tsx +++ b/src/components/Form/FieldNumber/index.tsx @@ -92,7 +92,7 @@ export const FieldNumber = < min={props.min} max={props.max} step={props.step} - bigStep={props.step} + bigStep={props.bigStep} isDisabled={props.isDisabled} {...props.inputNumberProps} {...field} diff --git a/src/components/InputNumber/index.tsx b/src/components/InputNumber/index.tsx index 99a832d25..cbe310a99 100644 --- a/src/components/InputNumber/index.tsx +++ b/src/components/InputNumber/index.tsx @@ -1,4 +1,4 @@ -import React, { ComponentProps, useEffect, useRef, useState } from 'react'; +import React, { ComponentProps, useRef, useState } from 'react'; import { Button, From 0f12e25552cd4fe6f7f3280d60f791470ec8e70d Mon Sep 17 00:00:00 2001 From: Yoann Fleury Date: Fri, 2 Aug 2024 17:40:17 +0200 Subject: [PATCH 7/7] fix: code review feedback on function to const --- src/components/InputNumber/index.tsx | 71 +++++++++++++--------------- 1 file changed, 34 insertions(+), 37 deletions(-) diff --git a/src/components/InputNumber/index.tsx b/src/components/InputNumber/index.tsx index cbe310a99..0a33b6457 100644 --- a/src/components/InputNumber/index.tsx +++ b/src/components/InputNumber/index.tsx @@ -1,4 +1,4 @@ -import React, { ComponentProps, useRef, useState } from 'react'; +import React, { ComponentProps, KeyboardEvent, useRef, useState } from 'react'; import { Button, @@ -79,24 +79,38 @@ export const InputNumber = forwardRef( const [isFocused, setIsFocused] = useState(false); const tmpValueRef = useRef(value ?? defaultValue ?? null); - const getNumericFormatOptions = () => - ({ - getInputRef: ref, - decimalScale: precision, - fixedDecimalScale: !isFocused ? fixedPrecision : false, - decimalSeparator: decimalsSeparator ?? '.', - thousandSeparator: groupSeparator ?? ',', - suffix: `${currency ? currencySuffix : ''}${suffix}`, - prefix: `${currency ? currencyPrefix : ''}${prefix}`, - onValueChange: (values) => { - tmpValueRef.current = values.floatValue ?? null; + const handleOnKeyDown = (e: KeyboardEvent) => { + const v = tmpValueRef.current; - // Prevent -0 to be replaced with 0 when input is controlled - if (values.floatValue === 0) return; + if (e.key === 'Enter') { + onChange(v); + } + if (e.key === 'ArrowUp') { + onChange(clamp((v ?? 0) + (e.shiftKey ? bigStep : step), { min, max })); + } + if (e.key === 'ArrowDown') { + onChange(clamp((v ?? 0) - (e.shiftKey ? bigStep : step), { min, max })); + } + rest.onKeyDown?.(e); + }; - onChange(values.floatValue ?? null); - }, - }) satisfies ComponentProps; + const getNumericFormatOptions = { + getInputRef: ref, + decimalScale: precision, + fixedDecimalScale: !isFocused ? fixedPrecision : false, + decimalSeparator: decimalsSeparator ?? '.', + thousandSeparator: groupSeparator ?? ',', + suffix: `${currency ? currencySuffix : ''}${suffix}`, + prefix: `${currency ? currencyPrefix : ''}${prefix}`, + onValueChange: (values) => { + tmpValueRef.current = values.floatValue ?? null; + + // Prevent -0 to be replaced with 0 when input is controlled + if (values.floatValue === 0) return; + + onChange(values.floatValue ?? null); + }, + } satisfies ComponentProps; return ( @@ -105,13 +119,13 @@ export const InputNumber = forwardRef( sx={{ fontVariantNumeric: 'tabular-nums' }} pe={showButtons ? 8 : undefined} {...rest} - {...getNumericFormatOptions()} + {...getNumericFormatOptions} value={value === undefined ? undefined : value ?? ''} defaultValue={defaultValue ?? undefined} placeholder={ typeof placeholder === 'number' ? numericFormatter(String(placeholder), { - ...getNumericFormatOptions(), + ...getNumericFormatOptions, fixedDecimalScale: fixedPrecision, }) : placeholder @@ -132,24 +146,7 @@ export const InputNumber = forwardRef( ); rest.onBlur?.(e); }} - onKeyDown={(e) => { - const v = tmpValueRef.current; - - if (e.key === 'Enter') { - onChange(v); - } - if (e.key === 'ArrowUp') { - onChange( - clamp((v ?? 0) + (e.shiftKey ? bigStep : step), { min, max }) - ); - } - if (e.key === 'ArrowDown') { - onChange( - clamp((v ?? 0) - (e.shiftKey ? bigStep : step), { min, max }) - ); - } - rest.onKeyDown?.(e); - }} + onKeyDown={handleOnKeyDown} /> {showButtons && (