From 75be05e42c95226592cdd2139894e9ff791280af Mon Sep 17 00:00:00 2001 From: Daria Date: Tue, 5 Nov 2024 13:18:00 +0300 Subject: [PATCH] feat(NumberInput): add new component (#1826) --- .../lab/NumberInput/NumberInput.scss | 42 ++ .../lab/NumberInput/NumberInput.tsx | 282 +++++++++ .../NumericArrows/NumericArrows.scss | 40 ++ .../NumericArrows/NumericArrows.tsx | 76 +++ src/components/lab/NumberInput/README.md | 415 ++++++++++++++ ...nder-story-Default-dark-chromium-linux.png | Bin 0 -> 1895 bytes ...render-story-Default-dark-webkit-linux.png | Bin 0 -> 1935 bytes ...der-story-Default-light-chromium-linux.png | Bin 0 -> 1767 bytes ...ender-story-Default-light-webkit-linux.png | Bin 0 -> 1787 bytes .../lab/NumberInput/__stories__/Docs.mdx | 7 + .../__stories__/NumberInput.stories.tsx | 209 +++++++ .../__tests__/NumberInput.test.tsx | 542 ++++++++++++++++++ .../__tests__/NumberInput.visual.test.tsx | 13 + .../lab/NumberInput/__tests__/stories.tsx | 5 + .../lab/NumberInput/__tests__/utils.test.ts | 192 +++++++ src/components/lab/NumberInput/i18n/en.json | 4 + src/components/lab/NumberInput/i18n/index.ts | 8 + src/components/lab/NumberInput/i18n/ru.json | 4 + src/components/lab/NumberInput/index.ts | 2 + src/components/lab/NumberInput/utils.ts | 210 +++++++ src/constants.ts | 5 + src/unstable.ts | 3 + 22 files changed, 2059 insertions(+) create mode 100644 src/components/lab/NumberInput/NumberInput.scss create mode 100644 src/components/lab/NumberInput/NumberInput.tsx create mode 100644 src/components/lab/NumberInput/NumericArrows/NumericArrows.scss create mode 100644 src/components/lab/NumberInput/NumericArrows/NumericArrows.tsx create mode 100644 src/components/lab/NumberInput/README.md create mode 100644 src/components/lab/NumberInput/__snapshots__/NumberInput.visual.test.tsx-snapshots/NumberInput-render-story-Default-dark-chromium-linux.png create mode 100644 src/components/lab/NumberInput/__snapshots__/NumberInput.visual.test.tsx-snapshots/NumberInput-render-story-Default-dark-webkit-linux.png create mode 100644 src/components/lab/NumberInput/__snapshots__/NumberInput.visual.test.tsx-snapshots/NumberInput-render-story-Default-light-chromium-linux.png create mode 100644 src/components/lab/NumberInput/__snapshots__/NumberInput.visual.test.tsx-snapshots/NumberInput-render-story-Default-light-webkit-linux.png create mode 100644 src/components/lab/NumberInput/__stories__/Docs.mdx create mode 100644 src/components/lab/NumberInput/__stories__/NumberInput.stories.tsx create mode 100644 src/components/lab/NumberInput/__tests__/NumberInput.test.tsx create mode 100644 src/components/lab/NumberInput/__tests__/NumberInput.visual.test.tsx create mode 100644 src/components/lab/NumberInput/__tests__/stories.tsx create mode 100644 src/components/lab/NumberInput/__tests__/utils.test.ts create mode 100644 src/components/lab/NumberInput/i18n/en.json create mode 100644 src/components/lab/NumberInput/i18n/index.ts create mode 100644 src/components/lab/NumberInput/i18n/ru.json create mode 100644 src/components/lab/NumberInput/index.ts create mode 100644 src/components/lab/NumberInput/utils.ts diff --git a/src/components/lab/NumberInput/NumberInput.scss b/src/components/lab/NumberInput/NumberInput.scss new file mode 100644 index 0000000000..2169078b24 --- /dev/null +++ b/src/components/lab/NumberInput/NumberInput.scss @@ -0,0 +1,42 @@ +@use '../../variables'; + +$block: '.#{variables.$ns}number-input'; + +#{$block} { + &_size { + &_s { + --_--textinput-end-padding: 1px; + } + + &_m { + --_--textinput-end-padding: 1px; + } + + &_l { + --_--textinput-end-padding: 3px; + } + + &_xl { + --_--textinput-end-padding: 3px; + } + } + + &_view_normal { + --_--arrows-border-color: var(--g-color-line-generic); + + &#{$block}_state_error { + --_--arrows-border-color: var(--g-color-line-danger); + } + } + + &_view_clear { + --_--arrows-border-color: transparent; + } + + &__arrows { + border-style: none; + border-inline-start-style: solid; + + margin-inline: var(--_--textinput-end-padding) calc(0px - var(--_--textinput-end-padding)); + } +} diff --git a/src/components/lab/NumberInput/NumberInput.tsx b/src/components/lab/NumberInput/NumberInput.tsx new file mode 100644 index 0000000000..97d347bb64 --- /dev/null +++ b/src/components/lab/NumberInput/NumberInput.tsx @@ -0,0 +1,282 @@ +'use client'; + +import React from 'react'; + +import {KeyCode} from '../../../constants'; +import {useControlledState, useForkRef} from '../../../hooks'; +import {useFormResetHandler} from '../../../hooks/private'; +import {TextInput} from '../../controls/TextInput'; +import type {BaseInputControlProps} from '../../controls/types'; +import {getInputControlState} from '../../controls/utils'; +import {block} from '../../utils/cn'; + +import {NumericArrows} from './NumericArrows/NumericArrows'; +import { + areStringRepresentationOfNumbersEqual, + clampToNearestStepValue, + getInputPattern, + getInternalState, + getParsedValue, + getPossibleNumberSubstring, + updateCursorPosition, +} from './utils'; + +import './NumberInput.scss'; + +const b = block('number-input'); + +export interface NumberInputProps + extends Omit< + BaseInputControlProps, + 'error' | 'value' | 'defaultValue' | 'onUpdate' + > { + /** The control's html attributes */ + controlProps?: Omit, 'min' | 'max' | 'onChange'>; + /** Help text rendered to the left of the input node */ + label?: string; + /** Indicates that the user cannot change control's value */ + readOnly?: boolean; + /** User`s node rendered before label and input node */ + startContent?: React.ReactNode; + /** User`s node rendered after input node and clear button */ + endContent?: React.ReactNode; + /** An optional element displayed under the lower right corner of the control and sharing the place with the error container */ + note?: React.ReactNode; + + /** Hides increment/decrement buttons at the end of control + */ + hiddenControls?: boolean; + /** min allowed value. It is used for clamping entered value to allowed range + * @default Number.MAX_SAFE_INTEGER + */ + min?: number; + /** max allowed value. It is used for clamping entered value to allowed range + * @default Number.MIN_SAFE_INTEGER + */ + max?: number; + /** Delta for incrementing/decrementing entered value with arrow keyboard buttons or component controls + * @default 1 + */ + step?: number; + /** Step multiplier when shift button is pressed + * @default 10 + */ + shiftMultiplier?: number; + /** Enables ability to enter decimal numbers + * @default false + */ + allowDecimal?: boolean; + /** The control's value */ + value?: number | null; + /** The control's default value. Use when the component is not controlled */ + defaultValue?: number | null; + /** Fires when the input’s value is changed by the user. Provides new value as an callback's argument */ + onUpdate?: (value: number | null) => void; +} + +function getStringValue(value: number | null) { + return value === null ? '' : String(value); +} + +export const NumberInput = React.forwardRef(function NumberInput( + {endContent, defaultValue: externalDefaultValue, ...props}, + ref, +) { + const { + value: externalValue, + onChange: handleChange, + onUpdate: externalOnUpdate, + min: externalMin, + max: externalMax, + shiftMultiplier: externalShiftMultiplier = 10, + step: externalStep = 1, + size = 'm', + view = 'normal', + disabled, + hiddenControls, + validationState, + onBlur, + onKeyDown, + allowDecimal = false, + className, + } = props; + + const { + min, + max, + step: baseStep, + value: internalValue, + defaultValue, + shiftMultiplier, + } = getInternalState({ + min: externalMin, + max: externalMax, + step: externalStep, + shiftMultiplier: externalShiftMultiplier, + allowDecimal, + value: externalValue, + defaultValue: externalDefaultValue, + }); + + const [value, setValue] = useControlledState( + internalValue, + defaultValue ?? null, + externalOnUpdate, + ); + + const [inputValue, setInputValue] = React.useState(getStringValue(value)); + + React.useEffect(() => { + const stringPropsValue = getStringValue(value); + setInputValue((currentInputValue) => { + if (!areStringRepresentationOfNumbersEqual(currentInputValue, stringPropsValue)) { + return stringPropsValue; + } + return currentInputValue; + }); + }, [value]); + + const clamp = true; + + const safeValue = value ?? 0; + + const state = getInputControlState(validationState); + + const canIncrementNumber = safeValue < (max ?? Number.MAX_SAFE_INTEGER); + + const canDecrementNumber = safeValue > (min ?? Number.MIN_SAFE_INTEGER); + + const innerControlRef = React.useRef(null); + const fieldRef = useFormResetHandler({ + initialValue: value, + onReset: setValue, + }); + const handleRef = useForkRef(props.controlRef, innerControlRef, fieldRef); + + const handleValueDelta = ( + e: + | React.MouseEvent + | React.WheelEvent + | React.KeyboardEvent, + direction: 'up' | 'down', + ) => { + const step = e.shiftKey ? shiftMultiplier * baseStep : baseStep; + const deltaWithSign = direction === 'up' ? step : -step; + if (direction === 'up' ? canIncrementNumber : canDecrementNumber) { + const newValue = clampToNearestStepValue({ + value: safeValue + deltaWithSign, + step: baseStep, + min, + max, + direction, + }); + setValue?.(newValue); + setInputValue(newValue.toString()); + } + }; + + const handleKeyDown: React.KeyboardEventHandler = (e) => { + if (e.key === KeyCode.ARROW_DOWN) { + e.preventDefault(); + handleValueDelta(e, 'down'); + } else if (e.key === KeyCode.ARROW_UP) { + e.preventDefault(); + handleValueDelta(e, 'up'); + } else if (e.key === KeyCode.HOME) { + e.preventDefault(); + if (min !== undefined) { + setValue?.(min); + setInputValue(min.toString()); + } + } else if (e.key === KeyCode.END) { + e.preventDefault(); + if (max !== undefined) { + const newValue = clampToNearestStepValue({ + value: max, + step: baseStep, + min, + max, + }); + setValue?.(newValue); + setInputValue(newValue.toString()); + } + } + onKeyDown?.(e); + }; + + const handleBlur: React.FocusEventHandler = (e) => { + if (clamp && value !== null) { + const clampedValue = clampToNearestStepValue({ + value, + step: baseStep, + min, + max, + }); + + if (value !== clampedValue) { + setValue?.(clampedValue); + } + setInputValue(clampedValue.toString()); + } + onBlur?.(e); + }; + + const handleUpdate = (v: string) => { + setInputValue(v); + const preparedStringValue = getPossibleNumberSubstring(v, allowDecimal); + updateCursorPosition(innerControlRef, v, preparedStringValue); + const {valid, value: parsedNumberValue} = getParsedValue(preparedStringValue); + if (valid && parsedNumberValue !== value) { + setValue?.(parsedNumberValue); + } + }; + + const handleInput: React.FormEventHandler = (e) => { + const preparedStringValue = getPossibleNumberSubstring(e.currentTarget.value, allowDecimal); + updateCursorPosition(innerControlRef, e.currentTarget.value, preparedStringValue); + }; + + return ( + + {endContent} + {hiddenControls ? null : ( + { + innerControlRef.current?.focus(); + handleValueDelta(e, 'up'); + }} + onDownClick={(e) => { + innerControlRef.current?.focus(); + handleValueDelta(e, 'down'); + }} + /> + )} + + } + /> + ); +}); diff --git a/src/components/lab/NumberInput/NumericArrows/NumericArrows.scss b/src/components/lab/NumberInput/NumericArrows/NumericArrows.scss new file mode 100644 index 0000000000..d4392989ef --- /dev/null +++ b/src/components/lab/NumberInput/NumericArrows/NumericArrows.scss @@ -0,0 +1,40 @@ +@use '../../../variables'; + +$block: '.#{variables.$ns}numeric-arrows'; + +#{$block} { + --_--border-width: var(--g-text-input-border-width, 1px); + + width: 24px; + height: fit-content; + + &, + &__separator { + border-width: var(--_--border-width); + border-color: var(--_--arrows-border-color); + } + + &_size { + &_s { + --g-button-height: 11px; + } + + &_m { + --g-button-height: 13px; + } + + &_l { + --g-button-height: 17px; + } + + &_xl { + --g-button-height: 21px; + } + } + + &__separator { + width: 100%; + height: 0px; + border-block-start-style: solid; + } +} diff --git a/src/components/lab/NumberInput/NumericArrows/NumericArrows.tsx b/src/components/lab/NumberInput/NumericArrows/NumericArrows.tsx new file mode 100644 index 0000000000..1c59c70492 --- /dev/null +++ b/src/components/lab/NumberInput/NumericArrows/NumericArrows.tsx @@ -0,0 +1,76 @@ +import React from 'react'; + +import {ChevronDown, ChevronUp} from '@gravity-ui/icons'; + +import {Button} from '../../../Button'; +import type {ButtonProps} from '../../../Button'; +import {Icon} from '../../../Icon'; +import type {InputControlSize} from '../../../controls/types'; +import {Flex} from '../../../layout'; +import {block} from '../../../utils/cn'; +import i18n from '../i18n'; +import {CONTROL_BUTTONS_QA, DECREMENT_BUTTON_QA, INCREMENT_BUTTON_QA} from '../utils'; + +import './NumericArrows.scss'; + +const b = block('numeric-arrows'); + +interface NumericArrowsProps extends React.HTMLAttributes<'div'> { + className?: string; + size: InputControlSize; + disabled?: boolean; + min?: number; + max?: number; + onUpClick: React.MouseEventHandler; + onDownClick: React.MouseEventHandler; +} + +export function NumericArrows({ + className, + size, + disabled, + onUpClick, + onDownClick, + ...restProps +}: NumericArrowsProps) { + const commonBtnProps: Partial = { + size: 's', + pin: 'brick-brick', + view: 'flat-secondary', + disabled, + tabIndex: -1, + width: 'max', + extraProps: { + 'aria-hidden': 'true', + }, + }; + + return ( + + + + + + ); +} diff --git a/src/components/lab/NumberInput/README.md b/src/components/lab/NumberInput/README.md new file mode 100644 index 0000000000..98d8324467 --- /dev/null +++ b/src/components/lab/NumberInput/README.md @@ -0,0 +1,415 @@ + + +# NumberInput + + + +```tsx +import {unstable_NumberInput as NumberInput} from '@gravity-ui/uikit/unstable'; +``` + +NumberInput allow users to enter numbers into a UI. + +## Appearance + +The appearance of `NumberInput` is controlled by the `view` and `pin` properties. + +### View + +`normal` - the main view of `NumberInput` (used by default). + + + +`clear` - can be used with a custom wrapper for `NumberInput`. + + + + + +```tsx + + +``` + + + +### Pin + +Allows you to control view of right and left edges of `NumberInput`'s border. + + + + + +```tsx + + + +``` + + + +## States + +### Disabled + +The state of the `NumberInput` where you don't want the user to be able to interact with the component. + + + + + +```tsx + +``` + + + +### Error + +The state of the `NumberInput` in which you want to indicate incorrect user input. To change `NumberInput` appearance, use the `validationState` property with the `"invalid"` value. An optional message text can be added via the `errorMessage` property. By default, message text is rendered outside the component. +This behaviour can be changed with the `errorPlacement` property. + + + + + +```tsx + + +``` + + + +## Size + +`s` – Used when standard controls are too big (tables, small cards). + +`m` – Basic size, used in most components. + +`l` – Basic controls performed in a page's header, modal windows, or pop-ups. + +`xl` – Used on promo and landing pages. + + + + + +```tsx + + + + +``` + + + +## Label + +Allows you to set the label to the left of control. + +- label is located to the right of the elements added via `startContent` property. +- label can take up no more than half the width of the entire NumberInput's space. + + + + + +```tsx + +``` + + + +## Additional content + +### Start content + +Allows you to add content to the left of the control. Located to the left of the label added via `label` property. + + + + + +```tsx +Left} /> +``` + + + +### End content + +Allows you to add content to the right of the control. Located to the right of the clear button added via `hasClear` property and inside-placed error icon. + + + + + +```tsx +Right} /> +``` + + + +### Controls + +Visibility of incrementing/decrementing arrow-controls in the rightmost position in the component can be managed by `hiddenControls` property. + + + + + +```tsx + + +``` + + + +## Behaviour + +### Min/max values + +Allow you to set minimum and maximum values, which can be entered to the input. Values which are not fit into defined range would be clamped on blur to the nearest allowed value. + + + + + +```tsx + +``` + + + +### Step + +Allows you to set the amount on which value in the input is incremented or decremented with arrow buttons in component controls or on keyboard. + +It also adds restrictions on allowed values and enabled clamping entered number on blur to allowed values by the following rules: + +- if step and min value are defined with integer values, then allowed values are defined as `min + (step * n)`, where `n` is an integer number; +- if step is an integer number and min value is not defined, then allowed values are defined as a divisible by step; +- if step or min values are decimal, then clamping is not applicable + + + + + +```tsx + +``` + + + +### Step multiplier + +Allows you to set the value by which the step value is multiplied when the Shift button on the keyboard is pressed by defining `shiftMultiplier` property. + + + + + +```tsx + + +``` + + + +### Decimal values restriction + +Allows you to switch ability to enter only integer or also decimal values by `allowDecimal` property. +With `allowDecimal={false}` property a dot entered to the input would be ignored and pasted decimal values would be rounded down. + + + + + +```tsx + + +``` + + + +## Properties + +| Name | Description | Type | Default | +| :-------------- | :---------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------: | :----------------: | +| allowDecimal | Enables ability to enter decimal numbers | `boolean` | `false` | +| autoComplete | The control's `autocomplete` attribute | `boolean` `string` | | +| autoFocus | The control's `autofocus` attribute | `boolean` | | +| className | The control's wrapper class name | `string` | | +| controlProps | The control's html attributes | `React.InputHTMLAttributes` | | +| controlRef | React ref provided to the control | `React.Ref` | | +| defaultValue | The control's default value, used when the component is not controlled | `number` `undefined` | | +| disabled | Indicates that the user cannot interact with the control | `boolean` | `false` | +| endContent | User`s node rendered after the input node, clear button and inside error icon | `React.ReactNode` | | +| errorMessage | Error text | `string` | | +| errorPlacement | Error placement | `outside` `inside` | `outside` | +| hasClear | Shows the icon for clearing control's value | `boolean` | `false` | +| hiddenControls | Hides increment/decrement buttons at the end of control | `boolean` | | +| id | The control's `id` attribute | `string` | | +| label | Help text rendered to the left of the input node | `string` | | +| max | max allowed value. It is used for clamping entered value to allowed range | `number` | `MAX_SAFE_INTEGER` | +| min | min allowed value. It is used for clamping entered value to allowed range | `number` | `MIN_SAFE_INTEGER` | +| name | The `name` attribute of the control. If unspecified, it will be autogenerated if not specified | `string` | | +| note | An optional element displayed under the bottom-right corner of the control that shares a space with the error container | `React.ReactNode` | | +| onBlur | Fires when the control lost focus. Provides focus event as a callback's argument | `function` | | +| onChange | Fires when the input’s value is changed by the user. Provides change event as an callback's argument | `function` | | +| onFocus | Fires when the control gets focus. Provides focus event as a callback's argument | `function` | | +| onKeyDown | Fires when a key is pressed. Provides keyboard event as a callback's argument | `function` | | +| onKeyUp | Fires when a key is released. Provides keyboard event as a callback's argument | `function` | | +| onUpdate | Fires when the input’s value is changed by the user. Provides new value as an callback's argument | `function` | | +| pin | The control's border view | `string` | `'round-round'` | +| placeholder | Text that appears in the control when it has no value set | `string` | | +| qa | Test ID attribute (`data-qa`) | `string` | | +| readonly | Indicates that the user cannot change control's value | `boolean` | `false` | +| shiftMultiplier | Step multiplier when shift button is pressed | `number` | `10` | +| size | The size of the control | `"s"` `"m"` `"l"` `"xl"` | `"m"` | +| step | Delta for incrementing/decrementing entered value with arrow keyboard buttons or component controls | `number` | `1` | +| startContent | The user`s node rendered before label and input | `React.ReactNode` | | +| tabIndex | The `tabindex` attribute of the control | `string` | | +| validationState | Validation state | `"invalid"` | | +| value | The value of the control | `number` `undefined` | | +| view | The view of the control | `"normal"` `"clear"` | `"normal"` | + +## CSS API + +Component does not have its own css api, but it extends parent TextInput component api diff --git a/src/components/lab/NumberInput/__snapshots__/NumberInput.visual.test.tsx-snapshots/NumberInput-render-story-Default-dark-chromium-linux.png b/src/components/lab/NumberInput/__snapshots__/NumberInput.visual.test.tsx-snapshots/NumberInput-render-story-Default-dark-chromium-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..695c912bc6d2289ba2fc3bc9a122d8e57b9c4ccb GIT binary patch literal 1895 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 DUU`*( literal 0 HcmV?d00001 diff --git a/src/components/lab/NumberInput/__snapshots__/NumberInput.visual.test.tsx-snapshots/NumberInput-render-story-Default-light-chromium-linux.png b/src/components/lab/NumberInput/__snapshots__/NumberInput.visual.test.tsx-snapshots/NumberInput-render-story-Default-light-chromium-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..fccc0847978dc978f7b2b58d4f2f6686e678317d GIT binary patch literal 1767 zcmc&#dpy%?82>FrxmKKtnW>zvq7R)jF_Z|$!9hr_#d0}E6lTn=Qj#O3)5OpzvdrzU zYO!NTYq`um?w6VABg5uLtXUhI^Kn3O<+1uWMCRm)Oqa9G&qdx}#%1@o`PoKjTgIt!I4o5HJBl($3 zrI-7qHS_VG+P}Xi_CB?pMHJ#RZk!H{pJct*mhGWEb&9dei5loC)*aAX)z7u+!<|q0 zj5e=7llo;6WRQ`|bMZy6?XC}!0pRjp9`-UJJeDRBiIPf5rL#zByrrx483M*HaM;SqibkU`!wfQz zIu2L)-G!8J_AW;K2-RYjRH2Zs4h{KGh8D0i5K9}3dUc^DblCvLe3zw#Y%r$9eti1? zQC}-0L(t1(mi4~K#V## z-h)F!Lt$ppHECSku>bk2@Ri9CXg|n`AzSZJzukUuEXk9P zH!J)tI{F82L7dDK6a%g7Wi&515p{Gav%S1qva*7ug!c)>JMY0_4b$afUq8a?4wvu! z=)xw0+_Oogl2!RRbD-OhEn+YjXtraS$J;8~{As*}z8k5ZzwhBjup-houIeYUt|y;% z^1J&{z~lg#ELfUix|gCW$x-~6zAIFT7s0+E1?8_`v9)5u$m9$D{&=xqu&clnX_I0R z*G5c1@RcU-doHAQp<>^?pIRCl8>1yI7NyhE*VCe-qhD`ENiB2nymaJ5>Z4Ww)x_Z1Rq3ZD0oZY6L-4<$4{1%5A6HHH$`mW=r zJ0B5IF$9e6Z1UYO_Nt9+W@S?^YdjYOK`32bS%63@wcXb}qu$z`X5Oh1m3Zy{c@EJJ z(5z@2-cc2kale{=J>(vnlHaE=2oe8>JX$-b6pF~olru4*SZ6OYKcHgYTH{yd+it4V z1-B?91{$r8hP< z(&-Z~R~NXn8lR)wa+UoX-L1O8jHae2kr-NA(nWmNS!}o;!JX7^q@wt`6dE|};^N}) zasTG8$5`45P0_oyCEVIk zK=3wdK)$^3wj0S@UvnC6kzC^=HuYw z!-40XC7s}5{IHfKbMdn2u7VAc^C7PG&%NY;JuKGqqP^4J$ef&WFh?ThpC>dH6I6&&BRRsq!#3x~N}u5p)4o;D^d)n0!th0s*1 zap*g6I16GtAyh^7SLxMll)sW<6lPVjS&!%!f zH`3HS1H+o;=H?|F+90nbeSIR}NRW<03(p<*{#tMY%_RLVDiyUyy~w-~V0u9r;^b}a zz6$D8{^D8D4qxxVFtbWwQ>}178%Da|q2u832WB+tRZ;Hs0d4sdZW^A!3yae=G?FTB lb*97Y@*$3Xf`4;YriDPbM0cE)RYE@(;OyXLUuzd~;~x$~j(h+B literal 0 HcmV?d00001 diff --git a/src/components/lab/NumberInput/__snapshots__/NumberInput.visual.test.tsx-snapshots/NumberInput-render-story-Default-light-webkit-linux.png b/src/components/lab/NumberInput/__snapshots__/NumberInput.visual.test.tsx-snapshots/NumberInput-render-story-Default-light-webkit-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..bc5d4239e7320f3892f90520de9dc21ea825f398 GIT binary patch literal 1787 zcmc&#X;hL~7zUl`q@zPkWo0g%W2Kf&=8}rrEIQ?elSxL3R4!-+ItngmnCVQpWd-4a z+nCD?6;_rh;+7>!qN6Q{h|8cTpr|RxD9C&@Kjz<@`PXyLx$k@KpLe^@^Jf1DJFc&5 zs0#vt^u11a1b{%`Ho(8RSqr$8ZpFTU*>u6@xCcn{;Z(NPk${JXULM~DCYR0&II!(} z!z+^qX*`4Lf=;luury;ZCd1KYWAtYBYGvn(7e(DZTmS5Ex|Ida60tmIB_o;8b)y24 z0fqK?d~5~!`W;1Xc5FTEES!wt8-P6ercFTS6erBTDZ*)Gd3{nNkB2OxN@~3cqAMy%ig$KdfUa`F9CgK!{BAm4yQp?mInyn&~f#S>MBN#FU6sDyH-9`L7-^nIT;H zQ_iB5=8-<>pPG$0scHuXR;fyyJ3@Fq0)iM@SS!G1m>H;ujg8f4x*DH7Lot(=L>#KLSn}TGI#70R z_>dhZ&e3XPjZ-+z9~(3I@*1f({1>8`s83I~uL>x>C20Y6fHWDWob0-`08uKJ!nhpg zB-#B350ILyon~bnJ806iUXnS|b-X=YAZ7YN@H3kmw`|Mc@WV%r z?DRZpnEz&*GML3;2~s5Q-^X|FN8h^alZNDGc2-*C3`RX^Z+AB-Snk*Mty@ZFwzrSt z6RdZIGugb}^mGw0NjTit$f%m!-`_7`(4_<-v5iK{TSsQhjr=1qE;ZzUi~L5bSJTte za{?Sf~HTmQd>;C>T4G#lZq#n}NJMVIK7F&RzW*K=OZf literal 0 HcmV?d00001 diff --git a/src/components/lab/NumberInput/__stories__/Docs.mdx b/src/components/lab/NumberInput/__stories__/Docs.mdx new file mode 100644 index 0000000000..37f9ff61f0 --- /dev/null +++ b/src/components/lab/NumberInput/__stories__/Docs.mdx @@ -0,0 +1,7 @@ +import {Meta, Markdown} from '@storybook/addon-docs'; +import * as Stories from './NumberInput.stories'; +import Readme from '../README.md?raw'; + + + +{Readme} diff --git a/src/components/lab/NumberInput/__stories__/NumberInput.stories.tsx b/src/components/lab/NumberInput/__stories__/NumberInput.stories.tsx new file mode 100644 index 0000000000..12e340a203 --- /dev/null +++ b/src/components/lab/NumberInput/__stories__/NumberInput.stories.tsx @@ -0,0 +1,209 @@ +import React from 'react'; + +import {ArrowShapeUpToLine} from '@gravity-ui/icons'; +import type {Meta, StoryObj} from '@storybook/react'; + +import {Showcase} from '../../../../demo/Showcase'; +import {Button} from '../../../Button'; +import {Icon} from '../../../Icon'; +import {Text} from '../../../Text'; +import {NumberInput} from '../NumberInput'; +import type {NumberInputProps} from '../NumberInput'; + +export default { + title: 'Lab/NumberInput', + component: NumberInput, + parameters: { + a11y: { + element: '#storybook-root', + config: { + rules: [ + { + id: 'color-contrast', + enabled: false, + selector: '.g-outer-additional-content', + }, + { + id: 'label-title-only', + enabled: false, + }, + { + id: 'label', + enabled: false, + }, + ], + }, + }, + }, +} as Meta; + +type Story = StoryObj; + +function StoryWithState(args: NumberInputProps) { + const [value, setValue] = React.useState(args.value ?? args.defaultValue ?? null); + return ; +} + +export const Default: Story = { + args: {}, + render: (args) => , +}; + +export const Behaviour: Story = { + args: {}, + render: (args) => ( + + + + + ), +}; + +export const Sizes: Story = { + args: { + ...Default.args, + }, + render: (args) => ( + + + + + + + ), +}; + +export const Errors: Story = { + args: { + ...Default.args, + validationState: 'invalid', + }, + render: (args) => ( + + + + + + ), +}; + +export const View: Story = { + args: { + ...Default.args, + }, + render: (args) => ( + + + + + + + ), +}; + +export const Controls: Story = { + args: { + ...Default.args, + }, + render: (args) => ( + + + + + + ), +}; + +export const AdditionalContent: Story = { + args: { + ...Default.args, + }, + render: (args) => ( + + + + + + + } + {...args} + /> + + + + } + {...args} + /> + + ), +}; + +export const Step: Story = { + args: { + ...Default.args, + }, + render: (args) => ( + + + + + + + + ), +}; + +export const MinMax: Story = { + args: { + ...Default.args, + }, + render: (args) => ( + + + + + ), +}; + +export const TextHints: Story = { + args: { + ...Default.args, + }, + render: (args) => ( + + + Additional} + {...args} + /> + + ), +}; diff --git a/src/components/lab/NumberInput/__tests__/NumberInput.test.tsx b/src/components/lab/NumberInput/__tests__/NumberInput.test.tsx new file mode 100644 index 0000000000..859360ba67 --- /dev/null +++ b/src/components/lab/NumberInput/__tests__/NumberInput.test.tsx @@ -0,0 +1,542 @@ +import React from 'react'; + +import userEvent from '@testing-library/user-event'; + +import {act, fireEvent, render, screen} from '../../../../../test-utils/utils'; +import {KeyCode} from '../../../../constants'; +import {CONTROL_ERROR_ICON_QA} from '../../../controls/utils'; +import {NumberInput} from '../NumberInput'; +import {CONTROL_BUTTONS_QA, DECREMENT_BUTTON_QA, INCREMENT_BUTTON_QA} from '../utils'; + +describe('NumberInput input', () => { + const getUpButton = () => screen.getByTestId(INCREMENT_BUTTON_QA); + const getDownButton = () => screen.getByTestId(DECREMENT_BUTTON_QA); + const getClearButton = () => screen.queryByRole('button', {name: 'Clear'}); + const getControls = () => screen.queryByTestId(CONTROL_BUTTONS_QA); + const getInput = () => screen.getByRole('spinbutton'); + + describe('basic', () => { + test('render input by default', () => { + render(); + const input = getInput(); + + expect(input).toBeVisible(); + expect(input.tagName.toLowerCase()).toBe('input'); + }); + + it('calls onUpdate and onChange on input change', async () => { + const handleUpdate = jest.fn(); + const handleChange = jest.fn(); + render(); + fireEvent.change(getInput(), {target: {value: '1'}}); + + expect(handleUpdate).toHaveBeenCalledWith(1); + expect(handleChange).toHaveBeenCalled(); + }); + + it('calls onUpdate and onChange on input paste', async () => { + const handleUpdate = jest.fn(); + const handleChange = jest.fn(); + render(); + fireEvent.change(getInput(), {target: {value: '- $123.45k'}}); + + expect(handleUpdate).toHaveBeenCalledWith(-123.45); + expect(handleChange).toHaveBeenCalled(); + }); + + it('shows empty input with undefined value', async () => { + const handleUpdate = jest.fn(); + const handleChange = jest.fn(); + render( + , + ); + + expect(getInput()).toHaveValue(''); + }); + + it('calls onUpdate and onChange only with valid chars', async () => { + const handleUpdate = jest.fn(); + const handleChange = jest.fn(); + render(); + fireEvent.change(getInput(), {target: {value: '123abc4.5'}}); + + expect(handleUpdate).toHaveBeenCalledWith(1234.5); + }); + + it('assumes comma as dot', async () => { + const handleUpdate = jest.fn(); + render(); + + fireEvent.change(getInput(), {target: {value: '1,2'}}); + + expect(handleUpdate).toHaveBeenCalledWith(1.2); + }); + + it('does not call onUpdate with incomplete number input (with minus sign only or trailing dot)', async () => { + const handleUpdate = jest.fn(); + const handleChange = jest.fn(); + render(); + fireEvent.change(getInput(), {target: {value: '-'}}); + fireEvent.change(getInput(), {target: {value: '-1.'}}); + + expect(handleUpdate).toHaveBeenCalledTimes(1); + expect(handleUpdate).toHaveBeenCalledWith(-1); + }); + + it('calls onFocus on increment/decrement button click', async () => { + const user = userEvent.setup(); + const handleFocus = jest.fn(); + render(); + + await user.click(getUpButton()); + expect(handleFocus).toHaveBeenCalledTimes(1); + }); + + it('ignores trailing dot when pasting uncomplete value', async () => { + const handleUpdate = jest.fn(); + render(); + fireEvent.change(getInput(), {target: {value: '1.'}}); + + expect(handleUpdate).toHaveBeenCalledWith(1); + }); + + it('removes trailling dot on blur', async () => { + const handleUpdate = jest.fn(); + render(); + const input = getInput(); + input.focus(); + fireEvent.change(getInput(), {target: {value: '1.'}}); + input.blur(); + + expect(handleUpdate).toHaveBeenLastCalledWith(1); + }); + + it('removes redundant zeros on blur', async () => { + const handleUpdate = jest.fn(); + render(); + const input = getInput(); + input.focus(); + fireEvent.change(getInput(), {target: {value: '00001.10000'}}); + input.blur(); + + expect(handleUpdate).toHaveBeenLastCalledWith(1.1); + }); + + it('sets min value on HOME button pressed when min defined', async () => { + const handleUpdate = jest.fn(); + const user = userEvent.setup(); + render(); + + await user.click(getInput()); + await user.keyboard(`{${KeyCode.HOME}}`); + + expect(handleUpdate).toHaveBeenCalledWith(5); + }); + + it('ignores HOME button press when min is not defined', async () => { + const handleUpdate = jest.fn(); + const user = userEvent.setup(); + render(); + + await user.click(getInput()); + await user.keyboard(`{${KeyCode.HOME}}`); + + expect(handleUpdate).not.toHaveBeenCalled(); + }); + + it('sets max value on END button pressed when max defined', async () => { + const handleUpdate = jest.fn(); + const user = userEvent.setup(); + render(); + + await user.click(getInput()); + await user.keyboard(`{${KeyCode.END}}`); + + expect(handleUpdate).toHaveBeenCalledWith(123); + }); + + it('ignores END button press when max is not defined', async () => { + const handleUpdate = jest.fn(); + const user = userEvent.setup(); + render(); + + await user.click(getInput()); + await user.keyboard(`{${KeyCode.END}}`); + + expect(handleUpdate).not.toHaveBeenCalled(); + }); + }); + + describe('min/max', () => { + it('clamps to custom min value on blur', () => { + const handleUpdate = jest.fn(); + render(); + const input = getInput(); + act(() => { + input.focus(); + input.blur(); + }); + + expect(handleUpdate).toHaveBeenLastCalledWith(-1); + }); + it('clamps to custom max value on blur', () => { + const handleUpdate = jest.fn(); + render(); + const input = getInput(); + act(() => { + input.focus(); + input.blur(); + }); + + expect(handleUpdate).toHaveBeenLastCalledWith(1000); + }); + it('clamps to default min value on blur', async () => { + const handleUpdate = jest.fn(); + render(); + const input = getInput(); + act(() => { + input.focus(); + input.blur(); + }); + + expect(handleUpdate).toHaveBeenLastCalledWith(Number.MIN_SAFE_INTEGER); + }); + it('clamps to default max value on blur', async () => { + const handleUpdate = jest.fn(); + render(); + const input = getInput(); + act(() => { + input.focus(); + input.blur(); + }); + + expect(handleUpdate).toHaveBeenLastCalledWith(Number.MAX_SAFE_INTEGER); + }); + it('clamps to divisible value on blur', async () => { + const handleUpdate = jest.fn(); + render(); + const input = getInput(); + act(() => { + input.focus(); + input.blur(); + }); + + expect(handleUpdate).toHaveBeenLastCalledWith(6); + }); + it('swaps min/max if max < min', async () => { + const handleUpdate = jest.fn(); + render(); + const input = getInput(); + act(() => { + input.focus(); + input.blur(); + }); + + expect(handleUpdate).toHaveBeenLastCalledWith(1000); + }); + + it('does not treats empty value as zero when clearing input', async () => { + const handleUpdate = jest.fn(); + render( + , + ); + + fireEvent.change(getInput(), {target: {value: ''}}); + expect(handleUpdate).toHaveBeenLastCalledWith(null); + }); + }); + + describe('render controls', () => { + it('render clear button', () => { + render(); + expect(getClearButton()).toBeInTheDocument(); + }); + + it('do not render clear button without hasClear prop', () => { + render(); + expect(getClearButton()).not.toBeInTheDocument(); + }); + + it('render increment/decrement control buttons', () => { + render(); + expect(getControls()).toBeInTheDocument(); + }); + + it('do not render increment/decrement control buttons with hiddenControls prop set to "true"', () => { + render(); + expect(getControls()).not.toBeInTheDocument(); + }); + + it('render error with inside placement', () => { + render( + , + ); + expect(screen.getByTestId(CONTROL_ERROR_ICON_QA)).toBeInTheDocument(); + }); + + it('render additional end content with default onClick handler', async () => { + const handleFocus = jest.fn(); + render( + my awesome endContent} />, + ); + const customContent = await screen.findByText('my awesome endContent'); + expect(customContent).toBeInTheDocument(); + + const user = userEvent.setup(); + await user.click(customContent); + expect(handleFocus).toHaveBeenCalled(); + }); + }); + + describe('increment/decrement', () => { + it('increments value on arrowUp button click', async () => { + const user = userEvent.setup(); + const handleUpdate = jest.fn(); + render(); + + await user.click(getUpButton()); + expect(handleUpdate).toHaveBeenCalledWith(2); + }); + + it('decrements value on arrowDown button click', async () => { + const user = userEvent.setup(); + const handleUpdate = jest.fn(); + render(); + + await user.click(getDownButton()); + expect(handleUpdate).toHaveBeenCalledWith(1); + }); + + it('clamps value to divisible on incrementation', async () => { + const user = userEvent.setup(); + const handleUpdate = jest.fn(); + render(); + + await user.click(getUpButton()); + expect(handleUpdate).toHaveBeenCalledWith(3); + }); + + it('treats empty value as zero when incrementing/decrementing', async () => { + const user = userEvent.setup(); + const handleUpdate = jest.fn(); + render(); + + await user.click(getUpButton()); + expect(handleUpdate).toHaveBeenCalledWith(1); + }); + + it('increments values by keyboard arrowUp', async () => { + const user = userEvent.setup(); + const handleUpdate = jest.fn(); + render(); + await user.click(getInput()); + + await user.keyboard(`{${KeyCode.ARROW_UP}}`); + expect(handleUpdate).toHaveBeenCalledWith(2); + }); + + it('decrements values by keyboard arrowDown', async () => { + const user = userEvent.setup(); + const handleUpdate = jest.fn(); + render(); + await user.click(getInput()); + + await user.keyboard(`{${KeyCode.ARROW_DOWN}}`); + expect(handleUpdate).toHaveBeenCalledWith(1); + }); + + it('uses external step value in controls', async () => { + const user = userEvent.setup(); + const handleUpdate = jest.fn(); + render( + , + ); + + await user.click(getUpButton()); + expect(handleUpdate).toHaveBeenCalledWith(1.2); + }); + + it('rounds down external step value without allowDecimal prop', async () => { + const user = userEvent.setup(); + const handleUpdate = jest.fn(); + render(); + + await user.click(getUpButton()); + expect(handleUpdate).toHaveBeenCalledWith(3); + }); + + it('uses external step value for keyboard events', async () => { + const user = userEvent.setup(); + const handleUpdate = jest.fn(); + render( + , + ); + await user.click(getInput()); + + await user.keyboard(`{${KeyCode.ARROW_UP}}`); + expect(handleUpdate).toHaveBeenCalledWith(1.2); + }); + + it('uses shiftMultiplier with Shift button pressed in incrementation with rendered control button', async () => { + const user = userEvent.setup(); + const handleUpdate = jest.fn(); + render( + , + ); + await user.click(getInput()); + + await user.keyboard(`{${KeyCode.SHIFT}>}`); + await user.click(getUpButton()); + await user.keyboard(`{/${KeyCode.SHIFT}}`); + expect(handleUpdate).toHaveBeenCalledWith(7); + }); + + it('rounds down shiftMultiplier without allowDecimal prop', async () => { + const user = userEvent.setup(); + const handleUpdate = jest.fn(); + render(); + await user.click(getInput()); + + await user.keyboard(`{${KeyCode.SHIFT}>}{${KeyCode.ARROW_UP}}{/${KeyCode.SHIFT}}`); + expect(handleUpdate).toHaveBeenCalledWith(8); + }); + + it('rounds down step and shiftMultiplier before multiplication', async () => { + const user = userEvent.setup(); + const handleUpdate = jest.fn(); + const {rerender} = render( + , + ); + await user.click(getInput()); + + await user.click(getUpButton()); + expect(handleUpdate).toHaveBeenNthCalledWith(1, 3); + + rerender( + , + ); + await user.keyboard(`{${KeyCode.SHIFT}>}`); + await user.keyboard(`{${KeyCode.ARROW_UP}}`); + await user.keyboard(`{/${KeyCode.SHIFT}}`); + expect(handleUpdate).toHaveBeenNthCalledWith(2, 17); + }); + }); + + describe('form', () => { + test('should submit empty value by default', async () => { + let value; + const onSubmit = jest.fn((e) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + value = [...formData.entries()]; + }); + render( +
+ + + , + ); + await userEvent.click(screen.getByTestId('submit')); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(value).toEqual([['numeric-field', '']]); + }); + + test('should submit default value', async () => { + let value; + const onSubmit = jest.fn((e) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + value = [...formData.entries()]; + }); + + render( +
+ + + , + ); + await userEvent.click(screen.getByTestId('submit')); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(value).toEqual([['numeric-field', '123']]); + }); + + test('should submit controlled value', async () => { + let value; + const onSubmit = jest.fn((e) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + value = [...formData.entries()]; + }); + render( +
+ + + , + ); + await userEvent.click(screen.getByTestId('submit')); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(value).toEqual([['numeric-field', '123']]); + }); + + test('supports form reset', async () => { + function Test() { + const [value, setValue] = React.useState(123); + return ( +
+ + + + ); + } + + render(); + // eslint-disable-next-line testing-library/no-node-access + const inputs = document.querySelectorAll('[name=numeric-field]'); + expect(inputs.length).toBe(1); + expect(inputs[0]).toHaveValue('123'); + + await userEvent.tab(); + await userEvent.keyboard('456'); + + expect(inputs[0]).toHaveValue('456'); + + const button = screen.getByTestId('reset'); + await userEvent.click(button); + expect(inputs[0]).toHaveValue('123'); + }); + }); +}); diff --git a/src/components/lab/NumberInput/__tests__/NumberInput.visual.test.tsx b/src/components/lab/NumberInput/__tests__/NumberInput.visual.test.tsx new file mode 100644 index 0000000000..d18e7918d3 --- /dev/null +++ b/src/components/lab/NumberInput/__tests__/NumberInput.visual.test.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +import {test} from '~playwright/core'; + +import {NumberInputStories} from './stories'; + +test.describe('NumberInput', () => { + test('render story: ', async ({mount, expectScreenshot}) => { + await mount(); + + await expectScreenshot(); + }); +}); diff --git a/src/components/lab/NumberInput/__tests__/stories.tsx b/src/components/lab/NumberInput/__tests__/stories.tsx new file mode 100644 index 0000000000..741c6548f0 --- /dev/null +++ b/src/components/lab/NumberInput/__tests__/stories.tsx @@ -0,0 +1,5 @@ +import {composeStories} from '@storybook/react'; + +import * as DefaultNumberInputStories from '../__stories__/NumberInput.stories'; + +export const NumberInputStories = composeStories(DefaultNumberInputStories); diff --git a/src/components/lab/NumberInput/__tests__/utils.test.ts b/src/components/lab/NumberInput/__tests__/utils.test.ts new file mode 100644 index 0000000000..95d5cda68f --- /dev/null +++ b/src/components/lab/NumberInput/__tests__/utils.test.ts @@ -0,0 +1,192 @@ +import {clampToNearestStepValue, getParsedValue, getPossibleNumberSubstring} from '../utils'; + +describe('NumberInput utils', () => { + describe('getPossibleNumberSubstring', () => { + describe('with allowDecimal', () => { + test('removes starting unit from negative integer number string', () => { + expect(getPossibleNumberSubstring('$$$123', true)).toBe('123'); + }); + test('removes trailing unit from negative integer number string', () => { + expect(getPossibleNumberSubstring('123k', true)).toBe('123'); + }); + test('removes units from negative integer number string', () => { + expect(getPossibleNumberSubstring('-$123k', true)).toBe('-123'); + }); + test('removes units from integer number string', () => { + expect(getPossibleNumberSubstring('$123k', true)).toBe('123'); + }); + test('removes units from negative number string with fraction', () => { + expect(getPossibleNumberSubstring('- $123 456,78k', true)).toBe('-123456.78'); + }); + test('removes units from number string with fraction without sign', () => { + expect(getPossibleNumberSubstring('$123.45k', true)).toBe('123.45'); + }); + test('returns undefined on invalid string', () => { + expect(getPossibleNumberSubstring('$abck', true)).toBe(undefined); + }); + test('returns number from valid chars', () => { + expect(getPossibleNumberSubstring('$123abc4.5k', true)).toBe('1234.5'); + }); + test('returns empty string on empty value', () => { + expect(getPossibleNumberSubstring('', true)).toBe(''); + }); + test('leaves unmodified sign only value', () => { + expect(getPossibleNumberSubstring('-', true)).toBe('-'); + }); + test('leaves unmodified uncompleted value', () => { + expect(getPossibleNumberSubstring('123.', true)).toBe('123.'); + }); + }); + describe('without allowDecimal', () => { + test('removes starting unit from negative integer number string', () => { + expect(getPossibleNumberSubstring('$$$123', false)).toBe('123'); + }); + test('removes trailing unit from negative integer number string', () => { + expect(getPossibleNumberSubstring('123k', false)).toBe('123'); + }); + test('removes units from negative integer number string', () => { + expect(getPossibleNumberSubstring('-$123k', false)).toBe('-123'); + }); + test('removes units from integer number string', () => { + expect(getPossibleNumberSubstring('$123k', false)).toBe('123'); + }); + test('removes units from negative number string with fraction', () => { + expect(getPossibleNumberSubstring('- $123 456,78k', false)).toBe('-12345678'); + }); + test('removes units from number string with fraction without sign', () => { + expect(getPossibleNumberSubstring('$123.45k', false)).toBe('12345'); + }); + test('returns number from valid chars', () => { + expect(getPossibleNumberSubstring('$123abc.45k', false)).toBe('12345'); + }); + test('returns undefined on invalid string', () => { + expect(getPossibleNumberSubstring('$abck', false)).toBe(undefined); + }); + test('returns number from valid chars', () => { + expect(getPossibleNumberSubstring('$123abc4.5k', false)).toBe('12345'); + }); + test('returns empty string on empty value', () => { + expect(getPossibleNumberSubstring('', false)).toBe(''); + }); + test('leaves unmodified sign only value', () => { + expect(getPossibleNumberSubstring('-', false)).toBe('-'); + }); + test('leaves unmodified uncompleted value', () => { + expect(getPossibleNumberSubstring('123.', false)).toBe('123'); + }); + }); + }); + describe('getParsedValue', () => { + it('returns value itself', () => { + expect(getParsedValue('-123.45')).toEqual({value: -123.45, valid: true}); + }); + it('returns undefined on sign-only value', () => { + expect(getParsedValue('-')).toEqual({value: null, valid: false}); + }); + it('returns integer value for uncompleted double value', () => { + expect(getParsedValue('123.')).toEqual({value: 123, valid: true}); + }); + it('returns zero on empty string', () => { + expect(getParsedValue('')).toEqual({value: null, valid: true}); + }); + it('returns undefined for NaN value', () => { + expect(getParsedValue('1ab2.5cdef')).toEqual({value: null, valid: false}); + }); + }); + describe('clampToNearestStepValue', () => { + const allDirections = [undefined, 'up', 'down'] as const; + + it('clamps value to bigger divisible on step with min value without direction', () => { + expect(clampToNearestStepValue({value: 10, step: 5, min: -3, max: undefined})).toBe(12); + }); + it('clamps value to smaller divisible on step with min value without direction', () => { + expect(clampToNearestStepValue({value: 10, step: 5, min: -2, max: undefined})).toBe(8); + }); + it('clamps value to bigger divisible on step with min value and direction=down', () => { + expect( + clampToNearestStepValue({ + value: 10, + step: 5, + min: -3, + max: undefined, + direction: 'down', + }), + ).toBe(12); + }); + it('clamps value to smaller divisible on step with min value and direction=down', () => { + expect( + clampToNearestStepValue({ + value: 10, + step: 5, + min: -3, + max: undefined, + direction: 'up', + }), + ).toBe(7); + }); + allDirections.forEach((direction) => + it(`clamps to min if value smaller with direction=${direction}`, () => { + expect( + clampToNearestStepValue({ + value: -10, + step: 5, + min: -2, + max: undefined, + direction, + }), + ).toBe(-2); + }), + ); + allDirections.forEach((direction) => + it(`clamps to max possible number if value greater than max with direction=${direction}`, () => { + expect( + clampToNearestStepValue({value: 105, step: 5, min: -2, max: 100, direction}), + ).toBe(98); + }), + ); + it('clamps to max if it is suitable', () => { + expect(clampToNearestStepValue({value: 97, step: 5, min: -2, max: 98})).toBe(98); + }); + it('clamps to min if it is suitable', () => { + expect(clampToNearestStepValue({value: 97, step: 5, min: 96, max: 1000})).toBe(96); + }); + it('leave value if it suitable', () => { + expect(clampToNearestStepValue({value: 8, step: 5, min: -2, max: undefined})).toBe(8); + }); + it('clamps to MAX_SAFE_INTEGER with big numbers if max is not defined', () => { + expect( + clampToNearestStepValue({ + value: Number.MAX_SAFE_INTEGER + 2, + step: 1, + min: -1, + max: undefined, + }), + ).toBe(Number.MAX_SAFE_INTEGER); + }); + it('clamps to MIN_SAFE_INTEGER with big numbers if min is not defined', () => { + expect( + clampToNearestStepValue({ + value: Number.MIN_SAFE_INTEGER - 2, + step: 1, + min: undefined, + max: undefined, + }), + ).toBe(Number.MIN_SAFE_INTEGER); + }); + it('uses zero as reference point if min is not defined', () => { + expect( + clampToNearestStepValue({ + value: 11, + step: 4, + min: undefined, + max: undefined, + }), + ).toBe(12); + }); + it('does not clamp decimal value', () => { + expect( + clampToNearestStepValue({value: 1.25, step: 8.25, min: -1, max: undefined}), + ).toBe(1.25); + }); + }); +}); diff --git a/src/components/lab/NumberInput/i18n/en.json b/src/components/lab/NumberInput/i18n/en.json new file mode 100644 index 0000000000..b828f9432d --- /dev/null +++ b/src/components/lab/NumberInput/i18n/en.json @@ -0,0 +1,4 @@ +{ + "label_increment": "Increment", + "label_decrement": "Decrement" +} diff --git a/src/components/lab/NumberInput/i18n/index.ts b/src/components/lab/NumberInput/i18n/index.ts new file mode 100644 index 0000000000..055e75d4ce --- /dev/null +++ b/src/components/lab/NumberInput/i18n/index.ts @@ -0,0 +1,8 @@ +import {addComponentKeysets} from '../../../../i18n'; + +import en from './en.json'; +import ru from './ru.json'; + +const COMPONENT = 'NumberInput'; + +export default addComponentKeysets({en, ru}, COMPONENT); diff --git a/src/components/lab/NumberInput/i18n/ru.json b/src/components/lab/NumberInput/i18n/ru.json new file mode 100644 index 0000000000..0ad8e05417 --- /dev/null +++ b/src/components/lab/NumberInput/i18n/ru.json @@ -0,0 +1,4 @@ +{ + "label_increment": "Увеличить", + "label_decrement": "Уменьшить" +} diff --git a/src/components/lab/NumberInput/index.ts b/src/components/lab/NumberInput/index.ts new file mode 100644 index 0000000000..78016b7e36 --- /dev/null +++ b/src/components/lab/NumberInput/index.ts @@ -0,0 +1,2 @@ +export {NumberInput} from './NumberInput'; +export type {NumberInputProps} from './NumberInput'; diff --git a/src/components/lab/NumberInput/utils.ts b/src/components/lab/NumberInput/utils.ts new file mode 100644 index 0000000000..d8afacf30d --- /dev/null +++ b/src/components/lab/NumberInput/utils.ts @@ -0,0 +1,210 @@ +export const INCREMENT_BUTTON_QA = 'increment-button-qa'; +export const DECREMENT_BUTTON_QA = 'decrement-button-qa'; +export const CONTROL_BUTTONS_QA = 'control-buttons-qa'; + +export function getInputPattern(withoutFraction: boolean, positiveOnly = false) { + return `^([${positiveOnly ? '' : '\\-'}\\+]?\\d+${withoutFraction ? '' : '(?:(?:.|,)?\\d+)?'})+$`; +} + +/* For parsing paste with units as "- $123.45k" + * Other strings with mixed numbers and letters/signs would be considered as invalid + * -------------------------------( $1 )-------($2 )( $3 ) ($4 )------ */ +const pastedInputParsingRegex = /^([-+]?)(?:\D*)(\d*)(\.|,)?(\d*)(?:\D*)$/; + +export function prepareStringValue(value: string): string { + return value + .replace(',', '.') + .replace(/\s/g, '') + .replace(/[^\d.+-]/g, ''); +} + +export function getPossibleNumberSubstring( + value: string, + allowDecimal: boolean, +): string | undefined { + const preparedString = prepareStringValue(value); + const match = pastedInputParsingRegex.exec(preparedString); + if (!match || (value.length > 0 && preparedString.length === 0)) { + return undefined; + } + + const possibleNumberString = [ + match[1], // sign + match[2], // integer part + allowDecimal ? match[3] : undefined, // dot + match[4], // fraction + ] + .filter(Boolean) + .join(''); + + return possibleNumberString; +} + +export function getParsedValue(value: string | undefined): {valid: boolean; value: number | null} { + if (value === undefined || value === '') { + return {valid: true, value: null}; + } + const parsedValueOrNaN = Number(value); + + const isValidValue = !Number.isNaN(parsedValueOrNaN); + const parsedValue = isValidValue ? parsedValueOrNaN : null; + + return {valid: isValidValue, value: parsedValue}; +} + +function roundIfNecessary(value: number, allowDecimal: boolean) { + return allowDecimal ? value : Math.floor(value); +} + +interface VariablesProps { + min: number | undefined; + max: number | undefined; + step: number; + shiftMultiplier: number; + value: number | null | undefined; + defaultValue: number | null | undefined; + allowDecimal: boolean; +} +export function getInternalState(props: VariablesProps): { + min: number | undefined; + max: number | undefined; + step: number; + shiftMultiplier: number; + value: number | null | undefined; + defaultValue: number | null | undefined; +} { + const { + min: externalMin, + max: externalMax, + step: externalStep, + shiftMultiplier: externalShiftMultiplier, + value: externalValue, + allowDecimal, + defaultValue: externalDefaultValue, + } = props; + + const {min: rangedMin, max: rangedMax} = + externalMin && externalMax && externalMin > externalMax + ? { + min: externalMax, + max: externalMin, + } + : {min: externalMin, max: externalMax}; + + const min = rangedMin && rangedMin >= Number.MIN_SAFE_INTEGER ? rangedMin : undefined; + const max = rangedMax && rangedMax <= Number.MAX_SAFE_INTEGER ? rangedMax : undefined; + + const step = roundIfNecessary(Math.abs(externalStep), allowDecimal) || 1; + const shiftMultiplier = roundIfNecessary(externalShiftMultiplier, allowDecimal) || 10; + const value = externalValue ? roundIfNecessary(externalValue, allowDecimal) : externalValue; + const defaultValue = externalDefaultValue + ? roundIfNecessary(externalDefaultValue, allowDecimal) + : externalDefaultValue; + + return {min, max, step, shiftMultiplier, value, defaultValue}; +} + +export function clampToNearestStepValue({ + value, + step, + min: originalMin, + max = Number.MAX_SAFE_INTEGER, + direction, +}: { + value: number; + step: number; + min: number | undefined; + max: number | undefined; + direction?: 'up' | 'down'; +}) { + const base = originalMin || 0; + const min = originalMin ?? Number.MIN_SAFE_INTEGER; + let clampedValue = toFixedNumber(value, step); + + if (clampedValue > max) { + clampedValue = max; + } else if (clampedValue < min) { + clampedValue = min; + } + if (!Number.isInteger(value) || !Number.isInteger(step)) { + // calculations with decimal values can bring inaccuracy with lots of zeros + return clampedValue; + } + const amountOfStepsDiff = Math.floor((clampedValue - base) / step); + const stepDeviation = clampedValue - base - step * amountOfStepsDiff; + + if (stepDeviation !== 0) { + const smallerPossibleValue = base + amountOfStepsDiff * step; + const greaterPossibleValue = base + (amountOfStepsDiff + 1) * step; + + const smallerValueIsPreferrable = direction + ? direction === 'up' + : greaterPossibleValue - clampedValue > clampedValue - smallerPossibleValue; + + if ( + (greaterPossibleValue > max || smallerValueIsPreferrable) && + smallerPossibleValue >= min + ) { + return smallerPossibleValue; + } + if (greaterPossibleValue <= max) { + return greaterPossibleValue; + } + } + + return toFixedNumber(clampedValue, step); +} + +export function updateCursorPosition( + inputRef: React.RefObject, + eventRawValue: string | undefined = '', + computedEventValue: string | undefined = '', +) { + const currentSelectionEndPosition = inputRef.current?.selectionEnd ?? eventRawValue.length; + if (eventRawValue !== computedEventValue) { + const startingPossiblyChangedPart = eventRawValue.slice(0, currentSelectionEndPosition); + const trailingUnchangedLength = eventRawValue.length - startingPossiblyChangedPart.length; + + const newStartingPart = computedEventValue.slice( + 0, + computedEventValue.length - trailingUnchangedLength, + ); + + inputRef.current?.setRangeText( + newStartingPart, + 0, + startingPossiblyChangedPart.length, + 'end', + ); + } +} + +// Useful when in input string '-1.' is typed and value={-1} prop passed. +// In this case we leave input string without replacing it by '-1'. +// Means that where is no need for replacing current input value with external value +export function areStringRepresentationOfNumbersEqual(v1: string, v2: string) { + if (v1 === v2) { + return true; + } + + const {valid: v1Valid, value: v1Value} = getParsedValue(v1); + const {valid: v2Valid, value: v2Value} = getParsedValue(v2); + + if (v1Valid && v2Valid) { + return v1Value === v2Value; + } + + const v1OnlyNumbers = v1.replace(/\D/g, ''); + const v2OnlyNumbers = v2.replace(/\D/g, ''); + + if (v1OnlyNumbers.length === v2OnlyNumbers.length && v1OnlyNumbers.length === 0) { + // exmpl, when just '-' typed and '' (equivalent for undefined) value passed + return true; + } + return false; +} + +function toFixedNumber(value: number, baseStep: number): number { + const stepDecimalDigits = baseStep.toString().split('.')[1]?.length || 0; + return parseFloat(value.toFixed(stepDecimalDigits)); +} diff --git a/src/constants.ts b/src/constants.ts index a5cae9e00c..cb85bf6609 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -10,4 +10,9 @@ export const KeyCode = { ARROW_DOWN: 'ArrowDown', ARROW_LEFT: 'ArrowLeft', ARROW_RIGHT: 'ArrowRight', + + SHIFT: 'Shift', + + HOME: 'Home', + END: 'End', }; diff --git a/src/unstable.ts b/src/unstable.ts index e68da813c7..872d5416e3 100644 --- a/src/unstable.ts +++ b/src/unstable.ts @@ -39,3 +39,6 @@ export type { BreadcrumbsProps as unstable_BreadcrumbsProps, BreadcrumbsItemProps as unstable_BreadcrumbsItemProps, } from './components/lab/Breadcrumbs'; + +export {NumberInput as unstable_NumberInput} from './components/lab/NumberInput'; +export type {NumberInputProps as unstable_NumberInputProps} from './components/lab/NumberInput';