Skip to content

Commit c535a1c

Browse files
committed
[number field] Improve parsing logic
1 parent 573f3a7 commit c535a1c

File tree

3 files changed

+355
-20
lines changed

3 files changed

+355
-20
lines changed

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

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -887,6 +887,177 @@ describe('<NumberField />', () => {
887887
});
888888
});
889889

890+
describe('integration: exotic inputs and IME', () => {
891+
it('parses Persian digits and separators via change events', async () => {
892+
const onValueChange = spy();
893+
function App() {
894+
const [value, setValue] = React.useState<number | null>(null);
895+
return (
896+
<NumberField
897+
value={value}
898+
onValueChange={(v) => {
899+
onValueChange(v);
900+
setValue(v);
901+
}}
902+
/>
903+
);
904+
}
905+
await render(<App />);
906+
907+
const input = screen.getByRole('textbox');
908+
// ۱۲٫۳۴ => 12.34
909+
fireEvent.change(input, { target: { value: '۱۲٫۳۴' } });
910+
911+
expect(onValueChange.callCount).to.equal(1);
912+
expect(onValueChange.firstCall.args[0]).to.equal(12.34);
913+
});
914+
915+
it('parses Persian digits with Arabic group/decimal separators', async () => {
916+
const onValueChange = spy();
917+
function App() {
918+
const [value, setValue] = React.useState<number | null>(null);
919+
return (
920+
<NumberField
921+
value={value}
922+
onValueChange={(v) => {
923+
onValueChange(v);
924+
setValue(v);
925+
}}
926+
/>
927+
);
928+
}
929+
await render(<App />);
930+
931+
const input = screen.getByRole('textbox');
932+
// ۱۲٬۳۴۵٫۶۷ => 12345.67
933+
fireEvent.change(input, { target: { value: '۱۲٬۳۴۵٫۶۷' } });
934+
935+
expect(onValueChange.callCount).to.equal(1);
936+
expect(onValueChange.firstCall.args[0]).to.equal(12345.67);
937+
});
938+
939+
it('parses fullwidth digits and punctuation', async () => {
940+
const onValueChange = spy();
941+
function App() {
942+
const [value, setValue] = React.useState<number | null>(null);
943+
return (
944+
<NumberField
945+
value={value}
946+
onValueChange={(v) => {
947+
onValueChange(v);
948+
setValue(v);
949+
}}
950+
/>
951+
);
952+
}
953+
954+
await render(<App />);
955+
956+
const input = screen.getByRole('textbox');
957+
958+
fireEvent.change(input, { target: { value: '1,234.56' } });
959+
960+
expect(onValueChange.callCount).to.equal(1);
961+
expect(onValueChange.firstCall.args[0]).to.equal(1234.56);
962+
});
963+
964+
it('parses percent and permille signs in exotic forms', async () => {
965+
const onValueChange = spy();
966+
function App() {
967+
const [value, setValue] = React.useState<number | null>(null);
968+
return (
969+
<NumberField
970+
value={value}
971+
onValueChange={(v) => {
972+
onValueChange(v);
973+
setValue(v);
974+
}}
975+
/>
976+
);
977+
}
978+
979+
await render(<App />);
980+
981+
const input = screen.getByRole('textbox');
982+
fireEvent.change(input, { target: { value: '١٢٪' } });
983+
984+
expect(onValueChange.callCount).to.equal(1);
985+
expect(onValueChange.firstCall.args[0]).to.equal(0.12);
986+
987+
// reset by typing again
988+
fireEvent.change(input, { target: { value: '12؉' } });
989+
expect(onValueChange.callCount).to.equal(2);
990+
expect(onValueChange.secondCall.args[0]).to.equal(0.012);
991+
});
992+
993+
it('parses trailing unicode minus and parentheses negatives', async () => {
994+
const onValueChange = spy();
995+
function App() {
996+
const [value, setValue] = React.useState<number | null>(null);
997+
return (
998+
<NumberField
999+
value={value}
1000+
onValueChange={(v) => {
1001+
onValueChange(v);
1002+
setValue(v);
1003+
}}
1004+
/>
1005+
);
1006+
}
1007+
1008+
await render(<App />);
1009+
1010+
const input = screen.getByRole('textbox');
1011+
fireEvent.change(input, { target: { value: '1234−' } });
1012+
1013+
expect(onValueChange.callCount).to.equal(1);
1014+
expect(onValueChange.firstCall.args[0]).to.equal(-1234);
1015+
1016+
fireEvent.change(input, { target: { value: '(1,234.5)' } });
1017+
1018+
expect(onValueChange.callCount).to.equal(2);
1019+
expect(onValueChange.secondCall.args[0]).to.equal(-1234.5);
1020+
});
1021+
1022+
it('collapses extra dots from mixed-locale inputs', async () => {
1023+
const onValueChange = spy();
1024+
function App() {
1025+
const [value, setValue] = React.useState<number | null>(null);
1026+
return (
1027+
<NumberField
1028+
value={value}
1029+
onValueChange={(v) => {
1030+
onValueChange(v);
1031+
setValue(v);
1032+
}}
1033+
/>
1034+
);
1035+
}
1036+
1037+
await render(<App />);
1038+
1039+
const input = screen.getByRole('textbox');
1040+
fireEvent.change(input, { target: { value: '1.234.567.89' } });
1041+
1042+
expect(onValueChange.callCount).to.equal(1);
1043+
expect(onValueChange.firstCall.args[0]).to.equal(1234567.89);
1044+
});
1045+
1046+
it('allows composition key events (IME) without preventing default', async () => {
1047+
await render(<NumberField />);
1048+
1049+
const input = screen.getByRole('textbox');
1050+
1051+
await act(async () => input.focus());
1052+
1053+
const preventDefaultSpy = spy();
1054+
1055+
// 229 indicates a composition key event
1056+
fireEvent.keyDown(input, { which: 229, preventDefault: preventDefaultSpy });
1057+
expect(preventDefaultSpy).to.have.property('callCount', 0);
1058+
});
1059+
});
1060+
8901061
describe.skipIf(isJSDOM)('pasting', () => {
8911062
it('should allow pasting a valid number', async () => {
8921063
await render(<NumberField />);

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

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,5 +52,70 @@ describe('NumberField parse', () => {
5252
it('handles percentages with style: "unit" and unit: "percent"', () => {
5353
expect(parseNumber('12%', 'en-US', { style: 'unit', unit: 'percent' })).to.equal(12);
5454
});
55+
56+
it('parses fullwidth digits and punctuation', () => {
57+
expect(parseNumber('1,234.56')).to.equal(1234.56);
58+
expect(parseNumber('12%')).to.equal(0.12);
59+
});
60+
61+
it('parses Persian digits', () => {
62+
expect(parseNumber('۱۲۳۴')).to.equal(1234);
63+
expect(parseNumber('۱۲٫۳۴')).to.equal(12.34);
64+
expect(parseNumber('۱۲٪')).to.equal(0.12);
65+
});
66+
67+
it('parses Persian digits with Arabic thousands and decimal characters', () => {
68+
// Uses Persian digits + Arabic separators (common in copied text)
69+
expect(parseNumber('۱۲٬۳۴۵٫۶۷')).to.equal(12345.67);
70+
});
71+
72+
it('parses permille values', () => {
73+
expect(parseNumber('12‰')).to.equal(0.012);
74+
expect(parseNumber('12؉')).to.equal(0.012);
75+
});
76+
77+
it('strips bidi/control characters', () => {
78+
expect(parseNumber('1\u200E234.56')).to.equal(1234.56);
79+
expect(parseNumber('\u200E12\u200F%')).to.equal(0.12);
80+
});
81+
82+
it('handles unicode minus and plus signs', () => {
83+
expect(parseNumber('−1234')).to.equal(-1234);
84+
expect(parseNumber('1234−')).to.equal(-1234);
85+
expect(parseNumber('+1234')).to.equal(1234);
86+
expect(parseNumber('1234+')).to.equal(1234);
87+
});
88+
89+
it('handles parentheses for negative numbers', () => {
90+
expect(parseNumber('(1,234.5)')).to.equal(-1234.5);
91+
expect(parseNumber('(12%)')).to.equal(-0.12);
92+
});
93+
94+
it('parses french formatted numbers with narrow no-break space grouping', () => {
95+
const fr = new Intl.NumberFormat('fr-FR').format(1234.5); // e.g., '1 234,5'
96+
expect(parseNumber(fr, 'fr-FR')).to.equal(1234.5);
97+
expect(parseNumber(`${fr}−`, 'fr-FR')).to.equal(-1234.5);
98+
});
99+
100+
it('parses currency when options specify currency style', () => {
101+
expect(parseNumber('$1,234.56', 'en-US', { style: 'currency', currency: 'USD' })).to.equal(
102+
1234.56,
103+
);
104+
});
105+
106+
it('parses units when options specify unit style', () => {
107+
expect(parseNumber('12 kg', 'en-US', { style: 'unit', unit: 'kilogram' })).to.equal(12);
108+
});
109+
110+
it('returns null for Infinity-like inputs', () => {
111+
expect(parseNumber('Infinity')).to.equal(null);
112+
expect(parseNumber('-Infinity')).to.equal(null);
113+
expect(parseNumber('∞')).to.equal(null);
114+
});
115+
116+
it('collapses extra dots from mixed-locale inputs', () => {
117+
// First '.' is decimal; subsequent '.' are removed
118+
expect(parseNumber('1.234.567.89')).to.equal(1234567.89);
119+
});
55120
});
56121
});

0 commit comments

Comments
 (0)