numora-react 2.0.5 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.tsx CHANGED
@@ -2,13 +2,14 @@ import React, {
2
2
  useRef,
3
3
  useState,
4
4
  useEffect,
5
+ useLayoutEffect,
5
6
  forwardRef,
6
- useImperativeHandle,
7
+ useCallback,
7
8
  } from 'react';
8
9
  import {
9
10
  FormatOn,
10
11
  ThousandStyle,
11
- formatValue,
12
+ formatValueForDisplay,
12
13
  type CaretPositionInfo,
13
14
  type FormattingOptions,
14
15
  } from 'numora';
@@ -19,13 +20,13 @@ import {
19
20
  handleNumoraOnPaste,
20
21
  } from './handlers';
21
22
 
22
- interface NumoraInputProps
23
+ export interface NumoraInputProps
23
24
  extends Omit<
24
25
  React.InputHTMLAttributes<HTMLInputElement>,
25
26
  'onChange' | 'type' | 'inputMode'
26
27
  > {
27
28
  maxDecimals?: number;
28
- onChange?: (e: React.ChangeEvent<HTMLInputElement> | React.ClipboardEvent<HTMLInputElement>) => void;
29
+ onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
29
30
 
30
31
  formatOn?: FormatOn;
31
32
  thousandSeparator?: string;
@@ -60,10 +61,11 @@ const NumoraInput = forwardRef<HTMLInputElement, NumoraInputProps>((props, ref)
60
61
  ...rest
61
62
  } = props;
62
63
 
63
- const inputRef = useRef<HTMLInputElement>(null);
64
+ const internalInputRef = useRef<HTMLInputElement>(null);
64
65
  const caretInfoRef = useRef<CaretPositionInfo | undefined>(undefined);
66
+ const lastCaretPosRef = useRef<number | null>(null);
65
67
 
66
- const formattingOptions: FormattingOptions & { rawValueMode?: boolean } = {
68
+ const formattingOptions: FormattingOptions = {
67
69
  formatOn,
68
70
  thousandSeparator,
69
71
  ThousandStyle: thousandStyle,
@@ -75,130 +77,150 @@ const NumoraInput = forwardRef<HTMLInputElement, NumoraInputProps>((props, ref)
75
77
  rawValueMode,
76
78
  };
77
79
 
78
- const getFormattedDefaultValue = (): string => {
79
- if (defaultValue !== undefined) {
80
- const { formatted } = formatValue(String(defaultValue), maxDecimals, formattingOptions);
80
+ const getInitialValue = (): string => {
81
+ const valueToFormat = controlledValue !== undefined ? controlledValue : defaultValue;
82
+ if (valueToFormat !== undefined) {
83
+ const { formatted } = formatValueForDisplay(String(valueToFormat), maxDecimals, formattingOptions);
81
84
  return formatted;
82
85
  }
83
86
  return '';
84
87
  };
85
88
 
86
- const getInitialControlledValue = (): string => {
87
- if (controlledValue !== undefined) {
88
- const { formatted } = formatValue(String(controlledValue), maxDecimals, formattingOptions);
89
- return formatted;
90
- }
91
- return '';
92
- };
89
+ const [displayValue, setDisplayValue] = useState<string>(getInitialValue);
93
90
 
94
- const [internalValue, setInternalValue] = useState<string>(getInitialControlledValue);
95
-
96
- useImperativeHandle(ref, () => inputRef.current as HTMLInputElement, []);
91
+ // Sync external ref with internal ref
92
+ useLayoutEffect(() => {
93
+ if (!ref) return;
94
+ if (typeof ref === 'function') {
95
+ ref(internalInputRef.current);
96
+ } else {
97
+ ref.current = internalInputRef.current;
98
+ }
99
+ }, [ref]);
97
100
 
98
- // When controlled value changes, normalize/format it for display
101
+ // When controlled value changes from outside, update display value
99
102
  useEffect(() => {
100
103
  if (controlledValue !== undefined) {
101
- const { formatted } = formatValue(String(controlledValue), maxDecimals, formattingOptions);
102
- setInternalValue(formatted);
103
- }
104
- }, [controlledValue, maxDecimals, formatOn, thousandSeparator, thousandStyle, decimalSeparator, decimalMinLength, enableCompactNotation, enableNegative, enableLeadingZeros, rawValueMode]);
105
-
106
- const isControlled = controlledValue !== undefined;
107
-
108
- const updateValue = (value: string) => {
109
- if (isControlled) {
110
- setInternalValue(value);
104
+ const { formatted } = formatValueForDisplay(String(controlledValue), maxDecimals, formattingOptions);
105
+ if (formatted !== displayValue) {
106
+ setDisplayValue(formatted);
107
+ }
111
108
  }
112
- };
113
-
114
- const syncEventValue = (
115
- target: HTMLInputElement,
116
- formattedValue: string,
117
- rawValue?: string
118
- ): void => {
119
- Object.defineProperty(target, 'value', {
120
- writable: true,
121
- value: formattedValue,
122
- });
123
-
124
- if (rawValue !== undefined) {
125
- Object.defineProperty(target, 'rawValue', {
126
- writable: true,
127
- value: rawValue,
128
- enumerable: true,
129
- configurable: true,
130
- });
109
+ }, [controlledValue, maxDecimals, formattingOptions]);
110
+
111
+ // Restore cursor position after render
112
+ useLayoutEffect(() => {
113
+ if (internalInputRef.current && lastCaretPosRef.current !== null) {
114
+ const input = internalInputRef.current;
115
+ const pos = lastCaretPosRef.current;
116
+ input.setSelectionRange(pos, pos);
117
+ lastCaretPosRef.current = null;
131
118
  }
132
- };
119
+ });
133
120
 
134
- const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
121
+ const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
135
122
  const { value, rawValue } = handleNumoraOnChange(e, {
136
123
  decimalMaxLength: maxDecimals,
137
124
  caretPositionBeforeChange: caretInfoRef.current,
138
125
  formattingOptions,
139
126
  });
127
+
128
+ // Store cursor position AFTER core library has calculated and set it
129
+ // The core library modifies the DOM element directly during handleNumoraOnChange
130
+ // Read from the input ref (which is the same element) to get the position set by the core library
131
+ if (internalInputRef.current) {
132
+ const cursorPos = internalInputRef.current.selectionStart;
133
+ if (cursorPos !== null && cursorPos !== undefined) {
134
+ lastCaretPosRef.current = cursorPos;
135
+ }
136
+ }
137
+
140
138
  caretInfoRef.current = undefined;
141
139
 
142
- syncEventValue(e.target, value, rawValue);
143
- updateValue(value);
140
+ // Add rawValue to the event object without overriding 'value' property
141
+ (e.target as any).rawValue = rawValue;
142
+
143
+ setDisplayValue(value);
144
144
 
145
145
  if (onChange) {
146
146
  onChange(e);
147
147
  }
148
- };
148
+ }, [maxDecimals, formattingOptions, onChange]);
149
+
150
+ const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
151
+ const coreCaretInfo = handleNumoraOnKeyDown(e, formattingOptions);
152
+
153
+ // Always capture cursor position info, even if core library doesn't return it
154
+ // This is needed for cursor position calculation during normal typing (not just Delete/Backspace)
155
+ if (!coreCaretInfo && internalInputRef.current) {
156
+ const selectionStart = internalInputRef.current.selectionStart ?? 0;
157
+ const selectionEnd = internalInputRef.current.selectionEnd ?? 0;
158
+ caretInfoRef.current = {
159
+ selectionStart,
160
+ selectionEnd,
161
+ };
162
+ } else {
163
+ caretInfoRef.current = coreCaretInfo;
164
+ }
165
+
166
+ if (onKeyDown) {
167
+ onKeyDown(e);
168
+ }
169
+ }, [formattingOptions, onKeyDown]);
149
170
 
150
- const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
171
+ const handlePaste = useCallback((e: React.ClipboardEvent<HTMLInputElement>) => {
151
172
  const { value, rawValue } = handleNumoraOnPaste(e, {
152
173
  decimalMaxLength: maxDecimals,
153
174
  formattingOptions,
154
175
  });
155
176
 
156
- syncEventValue(e.target as HTMLInputElement, value, rawValue);
157
- updateValue(value);
177
+ // For paste, we often want to move cursor to the end of pasted content
178
+ // handleNumoraOnPaste already handles DOM value and cursor, but React will overwrite it.
179
+ // So we capture where the core logic set the cursor.
180
+ lastCaretPosRef.current = (e.target as HTMLInputElement).selectionStart;
181
+ (e.target as any).rawValue = rawValue;
182
+
183
+ setDisplayValue(value);
158
184
 
159
185
  if (onPaste) {
160
186
  onPaste(e);
161
187
  }
162
- if (onChange) {
163
- onChange(e);
164
- }
165
- };
166
188
 
167
- const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
168
- caretInfoRef.current = handleNumoraOnKeyDown(e, formattingOptions);
169
- if (onKeyDown) {
170
- onKeyDown(e);
189
+ // Trigger onChange manually because paste event doesn't always trigger a ChangeEvent in all React versions
190
+ // when we preventDefault.
191
+ if (onChange) {
192
+ const changeEvent = e as unknown as React.ChangeEvent<HTMLInputElement>;
193
+ onChange(changeEvent);
171
194
  }
172
- };
195
+ }, [maxDecimals, formattingOptions, onPaste, onChange]);
173
196
 
174
- const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
197
+ const handleBlur = useCallback((e: React.FocusEvent<HTMLInputElement>) => {
175
198
  const { value, rawValue } = handleNumoraOnBlur(e, {
176
199
  decimalMaxLength: maxDecimals,
177
200
  formattingOptions,
178
201
  });
179
202
 
180
- syncEventValue(e.target, value, rawValue);
181
- updateValue(value);
203
+ (e.target as any).rawValue = rawValue;
204
+ setDisplayValue(value);
182
205
 
183
206
  if (onBlur) {
184
207
  onBlur(e);
185
208
  }
186
- };
209
+ }, [maxDecimals, formattingOptions, onBlur]);
187
210
 
188
211
  return (
189
212
  <input
190
213
  {...rest}
191
- ref={inputRef}
192
- {...(isControlled
193
- ? { value: internalValue }
194
- : { defaultValue: getFormattedDefaultValue() }
195
- )}
214
+ ref={internalInputRef}
215
+ value={displayValue}
196
216
  onChange={handleChange}
197
- onPaste={handlePaste}
198
217
  onKeyDown={handleKeyDown}
218
+ onPaste={handlePaste}
199
219
  onBlur={handleBlur}
200
220
  type="text"
201
221
  inputMode="decimal"
222
+ spellCheck={false}
223
+ autoComplete="off"
202
224
  />
203
225
  );
204
226
  });