Skip to content

Commit 43d3913

Browse files
committed
fix: improve keydown handling
1 parent a616679 commit 43d3913

File tree

4 files changed

+106
-14
lines changed

4 files changed

+106
-14
lines changed

packages/react/src/number-field/input/NumberFieldInput.test.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,58 @@ describe('<NumberField.Input />', () => {
108108
expect(input).to.have.value('10');
109109
});
110110

111+
it('allows unicode plus/minus, permille and fullwidth digits on keydown', async () => {
112+
await render(
113+
<NumberField.Root>
114+
<NumberField.Input />
115+
</NumberField.Root>,
116+
);
117+
118+
const input = screen.getByRole('textbox');
119+
await act(async () => input.focus());
120+
121+
function dispatchKey(key: string) {
122+
const evt = new window.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true });
123+
return input.dispatchEvent(evt);
124+
}
125+
126+
expect(dispatchKey('−')).to.equal(true); // MINUS SIGN U+2212
127+
expect(dispatchKey('+')).to.equal(true); // FULLWIDTH PLUS SIGN U+FF0B
128+
expect(dispatchKey('‰')).to.equal(true);
129+
expect(dispatchKey('1')).to.equal(true);
130+
});
131+
132+
it('applies locale-aware decimal/group gating (de-DE)', async () => {
133+
await render(
134+
<NumberField.Root locale="de-DE">
135+
<NumberField.Input />
136+
</NumberField.Root>,
137+
);
138+
139+
const input = screen.getByRole('textbox');
140+
await act(async () => input.focus());
141+
142+
const dispatchKey = (key: string) => {
143+
const evt = new window.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true });
144+
return input.dispatchEvent(evt);
145+
};
146+
147+
// de-DE: decimal is ',' and group is '.'
148+
// First comma is allowed
149+
expect(dispatchKey(',')).to.equal(true);
150+
// Simulate a typical user value with a digit before decimal to let change handler accept it
151+
fireEvent.change(input, { target: { value: '1,' } });
152+
expect(input).to.have.value('1,');
153+
154+
// Second comma should be blocked
155+
expect(dispatchKey(',')).to.equal(false);
156+
157+
// Grouping '.' should be allowed multiple times
158+
expect(dispatchKey('.')).to.equal(true);
159+
fireEvent.change(input, { target: { value: '1.,' } });
160+
expect(dispatchKey('.')).to.equal(true);
161+
});
162+
111163
it('commits formatted value only on blur', async () => {
112164
await render(
113165
<NumberField.Root>

packages/react/src/number-field/input/NumberFieldInput.tsx

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,15 @@ import { useFieldRootContext } from '../../field/root/FieldRootContext';
88
import { useFieldControlValidation } from '../../field/control/useFieldControlValidation';
99
import { fieldValidityMapping } from '../../field/utils/constants';
1010
import { DEFAULT_STEP } from '../utils/constants';
11-
import { ARABIC_RE, HAN_RE, getNumberLocaleDetails, parseNumber } from '../utils/parse';
11+
import {
12+
ARABIC_RE,
13+
HAN_RE,
14+
FULLWIDTH_RE,
15+
getNumberLocaleDetails,
16+
parseNumber,
17+
UNICODE_MINUS_SIGNS,
18+
UNICODE_PLUS_SIGNS,
19+
} from '../utils/parse';
1220
import type { NumberFieldRoot } from '../root/NumberFieldRoot';
1321
import { stateAttributesMapping as numberFieldStateAttributesMapping } from '../utils/stateAttributesMapping';
1422
import { useField } from '../../field/useField';
@@ -251,20 +259,40 @@ export const NumberFieldInput = React.forwardRef(function NumberFieldInput(
251259
let isAllowedNonNumericKey = allowedNonNumericKeys.has(event.key);
252260

253261
const { decimal, currency, percentSign } = getNumberLocaleDetails(
254-
[],
262+
locale,
255263
formatOptionsRef.current,
256264
);
257265

258266
const selectionStart = event.currentTarget.selectionStart;
259267
const selectionEnd = event.currentTarget.selectionEnd;
260268
const isAllSelected = selectionStart === 0 && selectionEnd === inputValue.length;
261269

262-
// Allow the minus key only if there isn't already a plus or minus sign, or if all the text
263-
// is selected, or if only the minus sign is highlighted.
264-
if (event.key === '-' && allowedNonNumericKeys.has('-')) {
265-
const isMinusHighlighted =
266-
selectionStart === 0 && selectionEnd === 1 && inputValue[0] === '-';
267-
isAllowedNonNumericKey = !inputValue.includes('-') || isAllSelected || isMinusHighlighted;
270+
// Normalize handling of plus/minus signs (ASCII and unicode variants)
271+
const anyMinus = ['-'].concat(UNICODE_MINUS_SIGNS);
272+
const anyPlus = ['+'].concat(UNICODE_PLUS_SIGNS);
273+
274+
const containsAny = (s: string, chars: string[]) => chars.some((ch) => s.includes(ch));
275+
const selectionIsExactlyCharAt = (index: number) =>
276+
selectionStart === index && selectionEnd === index + 1;
277+
278+
if (anyMinus.includes(event.key) && anyMinus.some((k) => allowedNonNumericKeys.has(k))) {
279+
// Only allow one sign unless replacing the existing one or all text is selected
280+
const existingIndex = anyMinus.map((ch) => inputValue.indexOf(ch)).find((i) => i !== -1);
281+
const isReplacingExisting =
282+
existingIndex != null && existingIndex !== -1 && selectionIsExactlyCharAt(existingIndex);
283+
isAllowedNonNumericKey =
284+
!containsAny(inputValue, anyMinus.concat(anyPlus)) ||
285+
isAllSelected ||
286+
isReplacingExisting;
287+
}
288+
if (anyPlus.includes(event.key) && anyPlus.some((k) => allowedNonNumericKeys.has(k))) {
289+
const existingIndex = anyPlus.map((ch) => inputValue.indexOf(ch)).find((i) => i !== -1);
290+
const isReplacingExisting =
291+
existingIndex != null && existingIndex !== -1 && selectionIsExactlyCharAt(existingIndex);
292+
isAllowedNonNumericKey =
293+
!containsAny(inputValue, anyMinus.concat(anyPlus)) ||
294+
isAllSelected ||
295+
isReplacingExisting;
268296
}
269297

270298
// Only allow one of each symbol.
@@ -281,6 +309,7 @@ export const NumberFieldInput = React.forwardRef(function NumberFieldInput(
281309
const isLatinNumeral = /^[0-9]$/.test(event.key);
282310
const isArabicNumeral = ARABIC_RE.test(event.key);
283311
const isHanNumeral = HAN_RE.test(event.key);
312+
const isFullwidthNumeral = FULLWIDTH_RE.test(event.key);
284313
const isNavigateKey = NAVIGATE_KEYS.has(event.key);
285314

286315
if (
@@ -294,6 +323,7 @@ export const NumberFieldInput = React.forwardRef(function NumberFieldInput(
294323
isAllowedNonNumericKey ||
295324
isLatinNumeral ||
296325
isArabicNumeral ||
326+
isFullwidthNumeral ||
297327
isHanNumeral ||
298328
isNavigateKey
299329
) {

packages/react/src/number-field/root/NumberFieldRoot.tsx

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,15 @@ import type { BaseUIComponentProps } from '../../utils/types';
1515
import type { FieldRoot } from '../../field/root/FieldRoot';
1616
import { stateAttributesMapping } from '../utils/stateAttributesMapping';
1717
import { useRenderElement } from '../../utils/useRenderElement';
18-
import { getNumberLocaleDetails, PERCENTAGES } from '../utils/parse';
18+
import {
19+
getNumberLocaleDetails,
20+
PERCENTAGES,
21+
UNICODE_MINUS_SIGNS,
22+
UNICODE_PLUS_SIGNS,
23+
PERMILLE,
24+
FULLWIDTH_DECIMAL,
25+
FULLWIDTH_GROUP,
26+
} from '../utils/parse';
1927
import { formatNumber, formatNumberMaxPrecision } from '../../utils/formatNumber';
2028
import { useBaseUiId } from '../../utils/useBaseUiId';
2129
import { CHANGE_VALUE_TICK_DELAY, DEFAULT_STEP, START_AUTO_CHANGE_DELAY } from '../utils/constants';
@@ -139,17 +147,19 @@ export const NumberFieldRoot = React.forwardRef(function NumberFieldRoot(
139147
const getAllowedNonNumericKeys = useEventCallback(() => {
140148
const { decimal, group, currency } = getNumberLocaleDetails(locale, format);
141149

142-
const keys = new Set(['.', ',', decimal, group]);
150+
const keys = new Set(['.', ',', decimal, group, FULLWIDTH_DECIMAL, FULLWIDTH_GROUP]);
143151

144152
if (formatStyle === 'percent') {
145153
PERCENTAGES.forEach((key) => keys.add(key));
146154
}
155+
// Permille is supported by the parser regardless of format style
156+
PERMILLE.forEach((key) => keys.add(key));
147157
if (formatStyle === 'currency' && currency) {
148158
keys.add(currency);
149159
}
150-
if (minWithDefault < 0) {
151-
keys.add('-');
152-
}
160+
// Allow plus sign in all cases; minus sign only when negatives are valid
161+
['+'].concat(UNICODE_PLUS_SIGNS).forEach((key) => keys.add(key));
162+
(minWithDefault < 0 ? ['-'].concat(UNICODE_MINUS_SIGNS) : []).forEach((key) => keys.add(key));
153163

154164
return keys;
155165
});

packages/react/src/number-field/utils/parse.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ describe('NumberField parse', () => {
114114
});
115115

116116
it('collapses extra dots from mixed-locale inputs', () => {
117-
// First '.' is decimal; subsequent '.' are removed
117+
// Last '.' is decimal; previous '.' are removed
118118
expect(parseNumber('1.234.567.89')).to.equal(1234567.89);
119119
});
120120

0 commit comments

Comments
 (0)