Skip to content

Commit

Permalink
feat(NumberInput): add new component (#1826)
Browse files Browse the repository at this point in the history
  • Loading branch information
DaryaLari authored Nov 5, 2024
1 parent e16fa1c commit 75be05e
Show file tree
Hide file tree
Showing 22 changed files with 2,059 additions and 0 deletions.
42 changes: 42 additions & 0 deletions src/components/lab/NumberInput/NumberInput.scss
Original file line number Diff line number Diff line change
@@ -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));
}
}
282 changes: 282 additions & 0 deletions src/components/lab/NumberInput/NumberInput.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>,
'error' | 'value' | 'defaultValue' | 'onUpdate'
> {
/** The control's html attributes */
controlProps?: Omit<React.InputHTMLAttributes<HTMLInputElement>, '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<HTMLSpanElement, NumberInputProps>(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<HTMLInputElement>(null);
const fieldRef = useFormResetHandler({
initialValue: value,
onReset: setValue,
});
const handleRef = useForkRef(props.controlRef, innerControlRef, fieldRef);

const handleValueDelta = (
e:
| React.MouseEvent<HTMLButtonElement>
| React.WheelEvent<HTMLInputElement>
| React.KeyboardEvent<HTMLInputElement>,
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<HTMLInputElement> = (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<HTMLInputElement> = (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<HTMLInputElement> = (e) => {
const preparedStringValue = getPossibleNumberSubstring(e.currentTarget.value, allowDecimal);
updateCursorPosition(innerControlRef, e.currentTarget.value, preparedStringValue);
};

return (
<TextInput
{...props}
className={b({size, view, state}, className)}
controlProps={{
onInput: handleInput,
...props.controlProps,
role: 'spinbutton',
inputMode: allowDecimal ? 'decimal' : 'numeric',
pattern: props.controlProps?.pattern ?? getInputPattern(allowDecimal, false),
'aria-valuemin': props.min,
'aria-valuemax': props.max,
'aria-valuenow': value === null ? undefined : value,
}}
controlRef={handleRef}
value={inputValue}
onChange={handleChange}
onUpdate={handleUpdate}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
ref={ref}
unstable_endContent={
<React.Fragment>
{endContent}
{hiddenControls ? null : (
<NumericArrows
className={b('arrows')}
size={size}
disabled={disabled}
onUpClick={(e) => {
innerControlRef.current?.focus();
handleValueDelta(e, 'up');
}}
onDownClick={(e) => {
innerControlRef.current?.focus();
handleValueDelta(e, 'down');
}}
/>
)}
</React.Fragment>
}
/>
);
});
40 changes: 40 additions & 0 deletions src/components/lab/NumberInput/NumericArrows/NumericArrows.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading

0 comments on commit 75be05e

Please sign in to comment.