Skip to content

Commit 75be05e

Browse files
authored
feat(NumberInput): add new component (#1826)
1 parent e16fa1c commit 75be05e

22 files changed

+2059
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
@use '../../variables';
2+
3+
$block: '.#{variables.$ns}number-input';
4+
5+
#{$block} {
6+
&_size {
7+
&_s {
8+
--_--textinput-end-padding: 1px;
9+
}
10+
11+
&_m {
12+
--_--textinput-end-padding: 1px;
13+
}
14+
15+
&_l {
16+
--_--textinput-end-padding: 3px;
17+
}
18+
19+
&_xl {
20+
--_--textinput-end-padding: 3px;
21+
}
22+
}
23+
24+
&_view_normal {
25+
--_--arrows-border-color: var(--g-color-line-generic);
26+
27+
&#{$block}_state_error {
28+
--_--arrows-border-color: var(--g-color-line-danger);
29+
}
30+
}
31+
32+
&_view_clear {
33+
--_--arrows-border-color: transparent;
34+
}
35+
36+
&__arrows {
37+
border-style: none;
38+
border-inline-start-style: solid;
39+
40+
margin-inline: var(--_--textinput-end-padding) calc(0px - var(--_--textinput-end-padding));
41+
}
42+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
'use client';
2+
3+
import React from 'react';
4+
5+
import {KeyCode} from '../../../constants';
6+
import {useControlledState, useForkRef} from '../../../hooks';
7+
import {useFormResetHandler} from '../../../hooks/private';
8+
import {TextInput} from '../../controls/TextInput';
9+
import type {BaseInputControlProps} from '../../controls/types';
10+
import {getInputControlState} from '../../controls/utils';
11+
import {block} from '../../utils/cn';
12+
13+
import {NumericArrows} from './NumericArrows/NumericArrows';
14+
import {
15+
areStringRepresentationOfNumbersEqual,
16+
clampToNearestStepValue,
17+
getInputPattern,
18+
getInternalState,
19+
getParsedValue,
20+
getPossibleNumberSubstring,
21+
updateCursorPosition,
22+
} from './utils';
23+
24+
import './NumberInput.scss';
25+
26+
const b = block('number-input');
27+
28+
export interface NumberInputProps
29+
extends Omit<
30+
BaseInputControlProps<HTMLInputElement>,
31+
'error' | 'value' | 'defaultValue' | 'onUpdate'
32+
> {
33+
/** The control's html attributes */
34+
controlProps?: Omit<React.InputHTMLAttributes<HTMLInputElement>, 'min' | 'max' | 'onChange'>;
35+
/** Help text rendered to the left of the input node */
36+
label?: string;
37+
/** Indicates that the user cannot change control's value */
38+
readOnly?: boolean;
39+
/** User`s node rendered before label and input node */
40+
startContent?: React.ReactNode;
41+
/** User`s node rendered after input node and clear button */
42+
endContent?: React.ReactNode;
43+
/** An optional element displayed under the lower right corner of the control and sharing the place with the error container */
44+
note?: React.ReactNode;
45+
46+
/** Hides increment/decrement buttons at the end of control
47+
*/
48+
hiddenControls?: boolean;
49+
/** min allowed value. It is used for clamping entered value to allowed range
50+
* @default Number.MAX_SAFE_INTEGER
51+
*/
52+
min?: number;
53+
/** max allowed value. It is used for clamping entered value to allowed range
54+
* @default Number.MIN_SAFE_INTEGER
55+
*/
56+
max?: number;
57+
/** Delta for incrementing/decrementing entered value with arrow keyboard buttons or component controls
58+
* @default 1
59+
*/
60+
step?: number;
61+
/** Step multiplier when shift button is pressed
62+
* @default 10
63+
*/
64+
shiftMultiplier?: number;
65+
/** Enables ability to enter decimal numbers
66+
* @default false
67+
*/
68+
allowDecimal?: boolean;
69+
/** The control's value */
70+
value?: number | null;
71+
/** The control's default value. Use when the component is not controlled */
72+
defaultValue?: number | null;
73+
/** Fires when the input’s value is changed by the user. Provides new value as an callback's argument */
74+
onUpdate?: (value: number | null) => void;
75+
}
76+
77+
function getStringValue(value: number | null) {
78+
return value === null ? '' : String(value);
79+
}
80+
81+
export const NumberInput = React.forwardRef<HTMLSpanElement, NumberInputProps>(function NumberInput(
82+
{endContent, defaultValue: externalDefaultValue, ...props},
83+
ref,
84+
) {
85+
const {
86+
value: externalValue,
87+
onChange: handleChange,
88+
onUpdate: externalOnUpdate,
89+
min: externalMin,
90+
max: externalMax,
91+
shiftMultiplier: externalShiftMultiplier = 10,
92+
step: externalStep = 1,
93+
size = 'm',
94+
view = 'normal',
95+
disabled,
96+
hiddenControls,
97+
validationState,
98+
onBlur,
99+
onKeyDown,
100+
allowDecimal = false,
101+
className,
102+
} = props;
103+
104+
const {
105+
min,
106+
max,
107+
step: baseStep,
108+
value: internalValue,
109+
defaultValue,
110+
shiftMultiplier,
111+
} = getInternalState({
112+
min: externalMin,
113+
max: externalMax,
114+
step: externalStep,
115+
shiftMultiplier: externalShiftMultiplier,
116+
allowDecimal,
117+
value: externalValue,
118+
defaultValue: externalDefaultValue,
119+
});
120+
121+
const [value, setValue] = useControlledState(
122+
internalValue,
123+
defaultValue ?? null,
124+
externalOnUpdate,
125+
);
126+
127+
const [inputValue, setInputValue] = React.useState(getStringValue(value));
128+
129+
React.useEffect(() => {
130+
const stringPropsValue = getStringValue(value);
131+
setInputValue((currentInputValue) => {
132+
if (!areStringRepresentationOfNumbersEqual(currentInputValue, stringPropsValue)) {
133+
return stringPropsValue;
134+
}
135+
return currentInputValue;
136+
});
137+
}, [value]);
138+
139+
const clamp = true;
140+
141+
const safeValue = value ?? 0;
142+
143+
const state = getInputControlState(validationState);
144+
145+
const canIncrementNumber = safeValue < (max ?? Number.MAX_SAFE_INTEGER);
146+
147+
const canDecrementNumber = safeValue > (min ?? Number.MIN_SAFE_INTEGER);
148+
149+
const innerControlRef = React.useRef<HTMLInputElement>(null);
150+
const fieldRef = useFormResetHandler({
151+
initialValue: value,
152+
onReset: setValue,
153+
});
154+
const handleRef = useForkRef(props.controlRef, innerControlRef, fieldRef);
155+
156+
const handleValueDelta = (
157+
e:
158+
| React.MouseEvent<HTMLButtonElement>
159+
| React.WheelEvent<HTMLInputElement>
160+
| React.KeyboardEvent<HTMLInputElement>,
161+
direction: 'up' | 'down',
162+
) => {
163+
const step = e.shiftKey ? shiftMultiplier * baseStep : baseStep;
164+
const deltaWithSign = direction === 'up' ? step : -step;
165+
if (direction === 'up' ? canIncrementNumber : canDecrementNumber) {
166+
const newValue = clampToNearestStepValue({
167+
value: safeValue + deltaWithSign,
168+
step: baseStep,
169+
min,
170+
max,
171+
direction,
172+
});
173+
setValue?.(newValue);
174+
setInputValue(newValue.toString());
175+
}
176+
};
177+
178+
const handleKeyDown: React.KeyboardEventHandler<HTMLInputElement> = (e) => {
179+
if (e.key === KeyCode.ARROW_DOWN) {
180+
e.preventDefault();
181+
handleValueDelta(e, 'down');
182+
} else if (e.key === KeyCode.ARROW_UP) {
183+
e.preventDefault();
184+
handleValueDelta(e, 'up');
185+
} else if (e.key === KeyCode.HOME) {
186+
e.preventDefault();
187+
if (min !== undefined) {
188+
setValue?.(min);
189+
setInputValue(min.toString());
190+
}
191+
} else if (e.key === KeyCode.END) {
192+
e.preventDefault();
193+
if (max !== undefined) {
194+
const newValue = clampToNearestStepValue({
195+
value: max,
196+
step: baseStep,
197+
min,
198+
max,
199+
});
200+
setValue?.(newValue);
201+
setInputValue(newValue.toString());
202+
}
203+
}
204+
onKeyDown?.(e);
205+
};
206+
207+
const handleBlur: React.FocusEventHandler<HTMLInputElement> = (e) => {
208+
if (clamp && value !== null) {
209+
const clampedValue = clampToNearestStepValue({
210+
value,
211+
step: baseStep,
212+
min,
213+
max,
214+
});
215+
216+
if (value !== clampedValue) {
217+
setValue?.(clampedValue);
218+
}
219+
setInputValue(clampedValue.toString());
220+
}
221+
onBlur?.(e);
222+
};
223+
224+
const handleUpdate = (v: string) => {
225+
setInputValue(v);
226+
const preparedStringValue = getPossibleNumberSubstring(v, allowDecimal);
227+
updateCursorPosition(innerControlRef, v, preparedStringValue);
228+
const {valid, value: parsedNumberValue} = getParsedValue(preparedStringValue);
229+
if (valid && parsedNumberValue !== value) {
230+
setValue?.(parsedNumberValue);
231+
}
232+
};
233+
234+
const handleInput: React.FormEventHandler<HTMLInputElement> = (e) => {
235+
const preparedStringValue = getPossibleNumberSubstring(e.currentTarget.value, allowDecimal);
236+
updateCursorPosition(innerControlRef, e.currentTarget.value, preparedStringValue);
237+
};
238+
239+
return (
240+
<TextInput
241+
{...props}
242+
className={b({size, view, state}, className)}
243+
controlProps={{
244+
onInput: handleInput,
245+
...props.controlProps,
246+
role: 'spinbutton',
247+
inputMode: allowDecimal ? 'decimal' : 'numeric',
248+
pattern: props.controlProps?.pattern ?? getInputPattern(allowDecimal, false),
249+
'aria-valuemin': props.min,
250+
'aria-valuemax': props.max,
251+
'aria-valuenow': value === null ? undefined : value,
252+
}}
253+
controlRef={handleRef}
254+
value={inputValue}
255+
onChange={handleChange}
256+
onUpdate={handleUpdate}
257+
onKeyDown={handleKeyDown}
258+
onBlur={handleBlur}
259+
ref={ref}
260+
unstable_endContent={
261+
<React.Fragment>
262+
{endContent}
263+
{hiddenControls ? null : (
264+
<NumericArrows
265+
className={b('arrows')}
266+
size={size}
267+
disabled={disabled}
268+
onUpClick={(e) => {
269+
innerControlRef.current?.focus();
270+
handleValueDelta(e, 'up');
271+
}}
272+
onDownClick={(e) => {
273+
innerControlRef.current?.focus();
274+
handleValueDelta(e, 'down');
275+
}}
276+
/>
277+
)}
278+
</React.Fragment>
279+
}
280+
/>
281+
);
282+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
@use '../../../variables';
2+
3+
$block: '.#{variables.$ns}numeric-arrows';
4+
5+
#{$block} {
6+
--_--border-width: var(--g-text-input-border-width, 1px);
7+
8+
width: 24px;
9+
height: fit-content;
10+
11+
&,
12+
&__separator {
13+
border-width: var(--_--border-width);
14+
border-color: var(--_--arrows-border-color);
15+
}
16+
17+
&_size {
18+
&_s {
19+
--g-button-height: 11px;
20+
}
21+
22+
&_m {
23+
--g-button-height: 13px;
24+
}
25+
26+
&_l {
27+
--g-button-height: 17px;
28+
}
29+
30+
&_xl {
31+
--g-button-height: 21px;
32+
}
33+
}
34+
35+
&__separator {
36+
width: 100%;
37+
height: 0px;
38+
border-block-start-style: solid;
39+
}
40+
}

0 commit comments

Comments
 (0)