@astryxdesign/core 0.5.0-canary.b0a09a6 → 0.5.0-canary.b79f0db
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/dist/NumberInput/numberParser.d.ts +16 -3
- package/dist/NumberInput/numberParser.d.ts.map +1 -1
- package/dist/NumberInput/numberParser.js +26 -9
- package/package.json +2 -2
- package/src/NumberInput/numberParser.docblock.test.ts +234 -0
- package/src/NumberInput/numberParser.test.ts +12 -0
- package/src/NumberInput/numberParser.ts +26 -9
- package/src/raw-import.d.ts +14 -0
|
@@ -7,9 +7,21 @@
|
|
|
7
7
|
* People paste numbers out of spreadsheets, and a spreadsheet writes them
|
|
8
8
|
* grouped, signed, and decorated. This reads those back. Everything locale
|
|
9
9
|
* specific — the grouping and decimal separators, the group sizes, the digit
|
|
10
|
-
* script — is derived from Intl and Unicode rather than a table, and
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* script — is derived from Intl and Unicode rather than a table, and text that
|
|
11
|
+
* no reading fits returns null so the field stays visibly invalid instead of
|
|
12
|
+
* committing a guess. Where both readings are well formed the locale's wins,
|
|
13
|
+
* the way utils/dateParser.ts settles an ambiguous date by locale preference.
|
|
14
|
+
* Grouping is well formed only when every group fits the locale's own sizes:
|
|
15
|
+
* the last exactly the primary size, each inner one exactly the secondary, the
|
|
16
|
+
* first no longer than that. So de-DE `1.234` is 1234, while `1.2345` (last
|
|
17
|
+
* group too long) and `1234.567` (too much ahead of it) keep the full stop as
|
|
18
|
+
* a decimal point. Groups of three fit every locale, so a separator repeated
|
|
19
|
+
* with three-digit groups is grouping in all of them and `1,234,567` reads the
|
|
20
|
+
* same everywhere; for a space or apostrophe, which no locale writes as a
|
|
21
|
+
* decimal point, one occurrence is enough, so en-US `1 234` is 1234.
|
|
22
|
+
*
|
|
23
|
+
* That paragraph is executable: numberParser.docblock.test.ts implements it as
|
|
24
|
+
* code, quote by quote, and fails when the parser and the words disagree.
|
|
13
25
|
*
|
|
14
26
|
* Which characters may separate digits at all is the one thing that is NOT
|
|
15
27
|
* read off the input: SEPARATOR_CHARS below is a bounded alphabet, the same
|
|
@@ -18,6 +30,7 @@
|
|
|
18
30
|
* SYNC: When modified, update these files to stay in sync:
|
|
19
31
|
* - /packages/core/src/NumberInput/NumberInput.tsx
|
|
20
32
|
* - /packages/core/src/NumberInput/numberParser.test.ts
|
|
33
|
+
* - /packages/core/src/NumberInput/numberParser.docblock.test.ts
|
|
21
34
|
*/
|
|
22
35
|
import type { Locale } from '../i18n';
|
|
23
36
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"numberParser.d.ts","sourceRoot":"","sources":["../../src/NumberInput/numberParser.ts"],"names":[],"mappings":"AAEA
|
|
1
|
+
{"version":3,"file":"numberParser.d.ts","sourceRoot":"","sources":["../../src/NumberInput/numberParser.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAEH,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,SAAS,CAAC;AAiPpC;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,MAAM,EACZ,MAAM,CAAC,EAAE,MAAM,GACd,MAAM,GAAG,IAAI,CA8Ef;AAaD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAI3E"}
|
|
@@ -9,9 +9,21 @@
|
|
|
9
9
|
* People paste numbers out of spreadsheets, and a spreadsheet writes them
|
|
10
10
|
* grouped, signed, and decorated. This reads those back. Everything locale
|
|
11
11
|
* specific — the grouping and decimal separators, the group sizes, the digit
|
|
12
|
-
* script — is derived from Intl and Unicode rather than a table, and
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* script — is derived from Intl and Unicode rather than a table, and text that
|
|
13
|
+
* no reading fits returns null so the field stays visibly invalid instead of
|
|
14
|
+
* committing a guess. Where both readings are well formed the locale's wins,
|
|
15
|
+
* the way utils/dateParser.ts settles an ambiguous date by locale preference.
|
|
16
|
+
* Grouping is well formed only when every group fits the locale's own sizes:
|
|
17
|
+
* the last exactly the primary size, each inner one exactly the secondary, the
|
|
18
|
+
* first no longer than that. So de-DE `1.234` is 1234, while `1.2345` (last
|
|
19
|
+
* group too long) and `1234.567` (too much ahead of it) keep the full stop as
|
|
20
|
+
* a decimal point. Groups of three fit every locale, so a separator repeated
|
|
21
|
+
* with three-digit groups is grouping in all of them and `1,234,567` reads the
|
|
22
|
+
* same everywhere; for a space or apostrophe, which no locale writes as a
|
|
23
|
+
* decimal point, one occurrence is enough, so en-US `1 234` is 1234.
|
|
24
|
+
*
|
|
25
|
+
* That paragraph is executable: numberParser.docblock.test.ts implements it as
|
|
26
|
+
* code, quote by quote, and fails when the parser and the words disagree.
|
|
15
27
|
*
|
|
16
28
|
* Which characters may separate digits at all is the one thing that is NOT
|
|
17
29
|
* read off the input: SEPARATOR_CHARS below is a bounded alphabet, the same
|
|
@@ -20,6 +32,7 @@
|
|
|
20
32
|
* SYNC: When modified, update these files to stay in sync:
|
|
21
33
|
* - /packages/core/src/NumberInput/NumberInput.tsx
|
|
22
34
|
* - /packages/core/src/NumberInput/numberParser.test.ts
|
|
35
|
+
* - /packages/core/src/NumberInput/numberParser.docblock.test.ts
|
|
23
36
|
*/
|
|
24
37
|
|
|
25
38
|
const symbolsByLocale = new Map();
|
|
@@ -64,15 +77,19 @@ const INVISIBLES = /[\u200B-\u200D\u200E\u200F\u061C\u2066-\u2069\uFEFF]/g;
|
|
|
64
77
|
const MINUS_SIGNS = '\u2212\u2012\u2013-';
|
|
65
78
|
|
|
66
79
|
/**
|
|
67
|
-
* Every character a locale writes between digits
|
|
68
|
-
*
|
|
69
|
-
*
|
|
80
|
+
* Every character a locale writes between digits. Sweeping every language ICU
|
|
81
|
+
* resolves, in every region, yields seven: comma, full stop, apostrophe,
|
|
82
|
+
* space (the whole space family folds to it under NFKC), the Arabic decimal
|
|
83
|
+
* and thousands separators, and U+060C — which only nqo writes, and which
|
|
84
|
+
* this alphabet leaves out. U+2019 is here in its place: no locale writes it,
|
|
85
|
+
* but the corpus pins it as the Swiss spelling.
|
|
70
86
|
*
|
|
71
87
|
* The alphabet is bounded on purpose. Read the candidates off the input
|
|
72
88
|
* instead and any repeated character is grouping, so a hyphenated ID commits
|
|
73
|
-
* as a number.
|
|
89
|
+
* as a number. It bounds characters and not shapes: a full stop is a real
|
|
90
|
+
* separator, so `192.168.100.200` is four well-formed groups.
|
|
74
91
|
*/
|
|
75
|
-
const SEPARATOR_CHARS = ",.' \u2019\
|
|
92
|
+
const SEPARATOR_CHARS = ",.' \u2019\u066B\u066C";
|
|
76
93
|
const NUMBER_BODY = new RegExp(`^[0-9${SEPARATOR_CHARS}]+$`);
|
|
77
94
|
/** No grouping and at most one full stop: the format `Number()` reads. */
|
|
78
95
|
const MACHINE_NUMBER = /^(?:\d+\.?\d*|\.\d+)$/;
|
|
@@ -167,7 +184,7 @@ function readUnder(core, group, decimal, primaryGroupSize, secondaryGroupSize, m
|
|
|
167
184
|
* beats the decimal-point reading.
|
|
168
185
|
*/
|
|
169
186
|
function isGroupOnlySeparator(separator) {
|
|
170
|
-
return /^[\s'\u2019
|
|
187
|
+
return /^[\s'\u2019]$/.test(separator);
|
|
171
188
|
}
|
|
172
189
|
function toPlainNumber(core, locale) {
|
|
173
190
|
if (!NUMBER_BODY.test(core)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/core",
|
|
3
|
-
"version": "0.5.0-canary.
|
|
3
|
+
"version": "0.5.0-canary.b79f0db",
|
|
4
4
|
"displayName": "Astryx Core",
|
|
5
5
|
"description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
|
|
6
6
|
"author": "Meta Open Source",
|
|
@@ -661,7 +661,7 @@
|
|
|
661
661
|
"react-dom": ">=19.0.0"
|
|
662
662
|
},
|
|
663
663
|
"devDependencies": {
|
|
664
|
-
"@astryxdesign/cli": "0.5.0-canary.
|
|
664
|
+
"@astryxdesign/cli": "0.5.0-canary.b79f0db",
|
|
665
665
|
"@babel/cli": "^8.0.4",
|
|
666
666
|
"@babel/core": "^7.29.7",
|
|
667
667
|
"@babel/preset-react": "^8.0.1",
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file numberParser.docblock.test.ts
|
|
5
|
+
* @input The rule paragraph in numberParser.ts, and the parser itself
|
|
6
|
+
* @output Nothing; fails when the two disagree
|
|
7
|
+
* @position Test-only; grades the prose in numberParser.ts against the code
|
|
8
|
+
*
|
|
9
|
+
* The file docblock of numberParser.ts states the rule the parser follows.
|
|
10
|
+
* A sentence like that rots silently — the code moves and the words stay —
|
|
11
|
+
* and the next person to change this file reads the words. So the paragraph
|
|
12
|
+
* is executable here: `predict` implements it, clause by clause, and every
|
|
13
|
+
* input is graded prose-against-parser.
|
|
14
|
+
*
|
|
15
|
+
* The one discipline that makes the number mean anything: `predict` may use
|
|
16
|
+
* ONLY what the paragraph says. Every branch below quotes the clause it
|
|
17
|
+
* implements, and those quotes are asserted verbatim against the source, so
|
|
18
|
+
* a paragraph that loses a clause fails here instead of drifting. If the
|
|
19
|
+
* parser grows a rule the words do not mention, this test goes red — that is
|
|
20
|
+
* the point, not a defect.
|
|
21
|
+
*
|
|
22
|
+
* SYNC: When modified, update these files to stay in sync:
|
|
23
|
+
* - /packages/core/src/NumberInput/numberParser.ts
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import {describe, it, expect} from 'vitest';
|
|
27
|
+
import {parseLocaleNumber} from './numberParser';
|
|
28
|
+
import SOURCE from './numberParser.ts?raw';
|
|
29
|
+
|
|
30
|
+
/** The docblock as running prose: comment markers and line breaks removed. */
|
|
31
|
+
const PROSE = SOURCE.replace(/^\s*\*\s?/gm, '').replace(/\s+/g, ' ');
|
|
32
|
+
|
|
33
|
+
/** Every clause `predict` is allowed to know, quoted from the docblock. */
|
|
34
|
+
const CLAUSES = {
|
|
35
|
+
'defer to the alphabet': 'SEPARATOR_CHARS below is a bounded alphabet',
|
|
36
|
+
'fold the space family': 'the whole space family folds to it under NFKC',
|
|
37
|
+
'refuse what no reading fits': 'text that no reading fits returns null',
|
|
38
|
+
'prefer the locale': 'Where both readings are well formed the locale',
|
|
39
|
+
'size every group':
|
|
40
|
+
"Grouping is well formed only when every group fits the locale's own " +
|
|
41
|
+
'sizes: the last exactly the primary size, each inner one exactly the ' +
|
|
42
|
+
'secondary, the first no longer than that.',
|
|
43
|
+
'fall back to the decimal point': 'keep the full stop as a decimal point',
|
|
44
|
+
'read three-digit groups anywhere':
|
|
45
|
+
'Groups of three fit every locale, so a separator repeated with ' +
|
|
46
|
+
'three-digit groups is grouping in all of them',
|
|
47
|
+
'need only one group-only occurrence':
|
|
48
|
+
'for a space or apostrophe, which no locale writes as a decimal point, ' +
|
|
49
|
+
'one occurrence is enough',
|
|
50
|
+
} as const;
|
|
51
|
+
|
|
52
|
+
/** The bounded alphabet the docblock defers to, read out of the source. */
|
|
53
|
+
const ALPHABET: ReadonlySet<string> = new Set(
|
|
54
|
+
JSON.parse(
|
|
55
|
+
`"${/const SEPARATOR_CHARS = "([^"]*)"/.exec(SOURCE)?.[1] ?? ''}"`,
|
|
56
|
+
),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const symbolsCache = new Map<string, ReturnType<typeof readSymbols>>();
|
|
60
|
+
function readSymbols(locale: string) {
|
|
61
|
+
const parts = new Intl.NumberFormat(locale).formatToParts(12345.6);
|
|
62
|
+
const sizes = new Intl.NumberFormat(locale)
|
|
63
|
+
.formatToParts(11111111111)
|
|
64
|
+
.filter(p => p.type === 'integer')
|
|
65
|
+
.map(p => p.value.length);
|
|
66
|
+
const grouped = sizes.length > 1;
|
|
67
|
+
return {
|
|
68
|
+
group: (parts.find(p => p.type === 'group')?.value ?? ',').normalize(
|
|
69
|
+
'NFKC',
|
|
70
|
+
),
|
|
71
|
+
decimal: parts.find(p => p.type === 'decimal')?.value ?? '.',
|
|
72
|
+
primary: grouped ? sizes[sizes.length - 1] : 3,
|
|
73
|
+
secondary: grouped ? sizes[sizes.length - 2] : 3,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function symbolsOf(locale: string) {
|
|
77
|
+
let s = symbolsCache.get(locale);
|
|
78
|
+
if (s === undefined) {
|
|
79
|
+
s = readSymbols(locale);
|
|
80
|
+
symbolsCache.set(locale, s);
|
|
81
|
+
}
|
|
82
|
+
return s;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** CLAUSES['size every group'], with the sizes it is handed. */
|
|
86
|
+
function groupsFit(chunks: string[], primary: number, secondary: number) {
|
|
87
|
+
return (
|
|
88
|
+
chunks[chunks.length - 1].length === primary &&
|
|
89
|
+
chunks[0].length <= secondary &&
|
|
90
|
+
chunks.slice(1, -1).every(chunk => chunk.length === secondary)
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** CLAUSES['need only one group-only occurrence']: "a space or apostrophe". */
|
|
95
|
+
const isSpaceOrApostrophe = (char: string) => /^[\s'\u2019]$/.test(char);
|
|
96
|
+
|
|
97
|
+
const SKIPPED = Symbol('outside the paragraph');
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The paragraph, as code. Digits with one separator character repeated
|
|
101
|
+
* between them is the shape it describes; anything else is not this
|
|
102
|
+
* paragraph's subject and is skipped rather than guessed at.
|
|
103
|
+
*/
|
|
104
|
+
function predict(input: string, locale: string): number | null | symbol {
|
|
105
|
+
// CLAUSES['fold the space family'].
|
|
106
|
+
const text = input.normalize('NFKC');
|
|
107
|
+
if (!/^\d/.test(text) || !/\d$/.test(text)) {
|
|
108
|
+
return SKIPPED;
|
|
109
|
+
}
|
|
110
|
+
const separators = [...new Set(text.replace(/\d/g, ''))];
|
|
111
|
+
if (separators.length !== 1) {
|
|
112
|
+
return SKIPPED;
|
|
113
|
+
}
|
|
114
|
+
const [separator] = separators;
|
|
115
|
+
|
|
116
|
+
// CLAUSES['defer to the alphabet'].
|
|
117
|
+
if (!ALPHABET.has(separator)) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const chunks = text.split(separator);
|
|
122
|
+
const {group, decimal, primary, secondary} = symbolsOf(locale);
|
|
123
|
+
|
|
124
|
+
// CLAUSES['prefer the locale'] + CLAUSES['size every group']: of the two
|
|
125
|
+
// locale readings, grouping wins wherever it is well formed.
|
|
126
|
+
if (separator === group && groupsFit(chunks, primary, secondary)) {
|
|
127
|
+
return Number(chunks.join(''));
|
|
128
|
+
}
|
|
129
|
+
// The locale's other reading. A decimal point appears once.
|
|
130
|
+
if (separator === decimal && chunks.length === 2) {
|
|
131
|
+
return Number(`${chunks[0]}.${chunks[1]}`);
|
|
132
|
+
}
|
|
133
|
+
// CLAUSES['fall back to the decimal point'], for a full stop the locale
|
|
134
|
+
// could read neither way.
|
|
135
|
+
if (separator === '.' && chunks.length === 2) {
|
|
136
|
+
return Number(text);
|
|
137
|
+
}
|
|
138
|
+
// CLAUSES['read three-digit groups anywhere'], and CLAUSES['need only one
|
|
139
|
+
// group-only occurrence'] for how many occurrences it takes.
|
|
140
|
+
const enough = isSpaceOrApostrophe(separator) ? 1 : 2;
|
|
141
|
+
if (chunks.length - 1 >= enough && groupsFit(chunks, 3, 3)) {
|
|
142
|
+
return Number(chunks.join(''));
|
|
143
|
+
}
|
|
144
|
+
// CLAUSES['refuse what no reading fits'].
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const LOCALES = ['en-US', 'de-DE', 'fr-FR', 'de-CH', 'en-IN', 'ar-SA'];
|
|
149
|
+
|
|
150
|
+
/** The shapes the paragraph names, plus the seams around each clause. */
|
|
151
|
+
const NAMED: [string, string][] = [
|
|
152
|
+
['1.234', 'de-DE'],
|
|
153
|
+
['1.2345', 'de-DE'],
|
|
154
|
+
['1234.567', 'de-DE'],
|
|
155
|
+
['12345.678', 'de-DE'],
|
|
156
|
+
['12.345', 'de-DE'],
|
|
157
|
+
['1.23', 'de-DE'],
|
|
158
|
+
['1,234,567', 'en-US'],
|
|
159
|
+
['1,234,567', 'de-DE'],
|
|
160
|
+
['1.234.567', 'en-US'],
|
|
161
|
+
['1234.567.890', 'de-DE'],
|
|
162
|
+
['1 234', 'en-US'],
|
|
163
|
+
['1\u00A0234', 'en-US'],
|
|
164
|
+
['1 234 567', 'en-US'],
|
|
165
|
+
['1234 567', 'fr-FR'],
|
|
166
|
+
["1'234", 'de-CH'],
|
|
167
|
+
["1'234", 'en-US'],
|
|
168
|
+
['1\u2019234', 'de-CH'],
|
|
169
|
+
['1,234', 'en-IN'],
|
|
170
|
+
['12,34,567', 'en-IN'],
|
|
171
|
+
['123,45,678', 'en-IN'],
|
|
172
|
+
['1,2,345', 'en-IN'],
|
|
173
|
+
['1\u066C234', 'ar-SA'],
|
|
174
|
+
['1\u066B5', 'ar-SA'],
|
|
175
|
+
['1\u00B7234\u00B7567', 'en-US'],
|
|
176
|
+
['123-456-789', 'en-US'],
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
/** Every one-, two- and three-separator shape over short digit runs. */
|
|
180
|
+
function* generated() {
|
|
181
|
+
const runs = ['1', '12', '123', '1234', '12345'];
|
|
182
|
+
for (const locale of LOCALES) {
|
|
183
|
+
for (const separator of ALPHABET) {
|
|
184
|
+
for (const a of runs) {
|
|
185
|
+
for (const b of runs) {
|
|
186
|
+
yield [`${a}${separator}${b}`, locale] as [string, string];
|
|
187
|
+
for (const c of runs) {
|
|
188
|
+
yield [`${a}${separator}${b}${separator}${c}`, locale] as [
|
|
189
|
+
string,
|
|
190
|
+
string,
|
|
191
|
+
];
|
|
192
|
+
for (const d of runs) {
|
|
193
|
+
yield [
|
|
194
|
+
`${a}${separator}${b}${separator}${c}${separator}${d}`,
|
|
195
|
+
locale,
|
|
196
|
+
] as [string, string];
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
describe('the numberParser docblock', () => {
|
|
206
|
+
it.each(Object.entries(CLAUSES))('still says: %s', (_name, quote) => {
|
|
207
|
+
expect(PROSE).toContain(quote);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it.each(NAMED)('reads %j in %s the way the parser does', (input, locale) => {
|
|
211
|
+
expect(predict(input, locale)).toBe(parseLocaleNumber(input, locale));
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('reads every generated shape the way the parser does', () => {
|
|
215
|
+
const disagreements: string[] = [];
|
|
216
|
+
let checked = 0;
|
|
217
|
+
for (const [input, locale] of generated()) {
|
|
218
|
+
const said = predict(input, locale);
|
|
219
|
+
if (said === SKIPPED) {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
checked++;
|
|
223
|
+
const did = parseLocaleNumber(input, locale);
|
|
224
|
+
if (!Object.is(said, did)) {
|
|
225
|
+
disagreements.push(
|
|
226
|
+
`${JSON.stringify(input)} in ${locale}: docblock ${String(said)}, parser ${String(did)}`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// The sweep is the evidence; a shrunken one silently proves nothing.
|
|
231
|
+
expect(checked).toBeGreaterThan(30000);
|
|
232
|
+
expect(disagreements.slice(0, 10)).toEqual([]);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -6,6 +6,7 @@ import {formatEditableNumber, parseLocaleNumber} from './numberParser';
|
|
|
6
6
|
const NBSP = '\u00A0';
|
|
7
7
|
const NARROW_NBSP = '\u202F';
|
|
8
8
|
const APOSTROPHE = '\u2019';
|
|
9
|
+
const MIDDLE_DOT = '\u00B7';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* The contract, enumerated. A row is `[input, locale, expected]`, and `null`
|
|
@@ -53,6 +54,9 @@ const CORPUS: [string, string, number | null][] = [
|
|
|
53
54
|
['1.5e3', 'de-DE', 1500],
|
|
54
55
|
['1.234', 'de-DE', 1234],
|
|
55
56
|
['1.2345', 'de-DE', 1.2345],
|
|
57
|
+
// The same text in the other comma-decimal locale: fr-FR groups with a
|
|
58
|
+
// space, so a full stop has no grouping reading there and stays the point.
|
|
59
|
+
['1.234', 'fr-FR', 1.234],
|
|
56
60
|
|
|
57
61
|
// Decimals and signs.
|
|
58
62
|
['1,234.56', 'en-US', 1234.56],
|
|
@@ -142,6 +146,11 @@ const CORPUS: [string, string, number | null][] = [
|
|
|
142
146
|
['800-555-1212', 'en-US', null],
|
|
143
147
|
['2024-01-15', 'en-US', null],
|
|
144
148
|
|
|
149
|
+
// U+00B7 was in the alphabet as the Catalan middle dot. Catalan groups with
|
|
150
|
+
// a full stop, and no locale ICU resolves writes it between digits at all.
|
|
151
|
+
[`1${MIDDLE_DOT}234${MIDDLE_DOT}567`, 'en-US', null],
|
|
152
|
+
[`1${MIDDLE_DOT}234${MIDDLE_DOT}567`, 'ca-ES', null],
|
|
153
|
+
|
|
145
154
|
// The field has no percent semantics, so a percent sign is not dropped:
|
|
146
155
|
// committing 45 for `45%` is off by a factor of a hundred.
|
|
147
156
|
['45%', 'en-US', null],
|
|
@@ -174,6 +183,9 @@ describe('formatEditableNumber', () => {
|
|
|
174
183
|
[1.5, 'en-US', '1.5'],
|
|
175
184
|
[1.5, 'de-DE', '1,5'],
|
|
176
185
|
[-1234.56, 'fr-FR', '-1234,56'],
|
|
186
|
+
// Only the decimal separator is localized. The digits stay as `String`
|
|
187
|
+
// writes them, which is what the field shows at rest when no `formatValue`
|
|
188
|
+
// is given, so localizing them here would flip the script on focus.
|
|
177
189
|
[3.5, 'ar-SA', '3٫5'],
|
|
178
190
|
// No grouping: the text has to be editable, and a separator the person did
|
|
179
191
|
// not type is one they have to delete.
|
|
@@ -9,9 +9,21 @@
|
|
|
9
9
|
* People paste numbers out of spreadsheets, and a spreadsheet writes them
|
|
10
10
|
* grouped, signed, and decorated. This reads those back. Everything locale
|
|
11
11
|
* specific — the grouping and decimal separators, the group sizes, the digit
|
|
12
|
-
* script — is derived from Intl and Unicode rather than a table, and
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* script — is derived from Intl and Unicode rather than a table, and text that
|
|
13
|
+
* no reading fits returns null so the field stays visibly invalid instead of
|
|
14
|
+
* committing a guess. Where both readings are well formed the locale's wins,
|
|
15
|
+
* the way utils/dateParser.ts settles an ambiguous date by locale preference.
|
|
16
|
+
* Grouping is well formed only when every group fits the locale's own sizes:
|
|
17
|
+
* the last exactly the primary size, each inner one exactly the secondary, the
|
|
18
|
+
* first no longer than that. So de-DE `1.234` is 1234, while `1.2345` (last
|
|
19
|
+
* group too long) and `1234.567` (too much ahead of it) keep the full stop as
|
|
20
|
+
* a decimal point. Groups of three fit every locale, so a separator repeated
|
|
21
|
+
* with three-digit groups is grouping in all of them and `1,234,567` reads the
|
|
22
|
+
* same everywhere; for a space or apostrophe, which no locale writes as a
|
|
23
|
+
* decimal point, one occurrence is enough, so en-US `1 234` is 1234.
|
|
24
|
+
*
|
|
25
|
+
* That paragraph is executable: numberParser.docblock.test.ts implements it as
|
|
26
|
+
* code, quote by quote, and fails when the parser and the words disagree.
|
|
15
27
|
*
|
|
16
28
|
* Which characters may separate digits at all is the one thing that is NOT
|
|
17
29
|
* read off the input: SEPARATOR_CHARS below is a bounded alphabet, the same
|
|
@@ -20,6 +32,7 @@
|
|
|
20
32
|
* SYNC: When modified, update these files to stay in sync:
|
|
21
33
|
* - /packages/core/src/NumberInput/NumberInput.tsx
|
|
22
34
|
* - /packages/core/src/NumberInput/numberParser.test.ts
|
|
35
|
+
* - /packages/core/src/NumberInput/numberParser.docblock.test.ts
|
|
23
36
|
*/
|
|
24
37
|
|
|
25
38
|
import type {Locale} from '../i18n';
|
|
@@ -81,15 +94,19 @@ const INVISIBLES = /[\u200B-\u200D\u200E\u200F\u061C\u2066-\u2069\uFEFF]/g;
|
|
|
81
94
|
const MINUS_SIGNS = '\u2212\u2012\u2013-';
|
|
82
95
|
|
|
83
96
|
/**
|
|
84
|
-
* Every character a locale writes between digits
|
|
85
|
-
*
|
|
86
|
-
*
|
|
97
|
+
* Every character a locale writes between digits. Sweeping every language ICU
|
|
98
|
+
* resolves, in every region, yields seven: comma, full stop, apostrophe,
|
|
99
|
+
* space (the whole space family folds to it under NFKC), the Arabic decimal
|
|
100
|
+
* and thousands separators, and U+060C — which only nqo writes, and which
|
|
101
|
+
* this alphabet leaves out. U+2019 is here in its place: no locale writes it,
|
|
102
|
+
* but the corpus pins it as the Swiss spelling.
|
|
87
103
|
*
|
|
88
104
|
* The alphabet is bounded on purpose. Read the candidates off the input
|
|
89
105
|
* instead and any repeated character is grouping, so a hyphenated ID commits
|
|
90
|
-
* as a number.
|
|
106
|
+
* as a number. It bounds characters and not shapes: a full stop is a real
|
|
107
|
+
* separator, so `192.168.100.200` is four well-formed groups.
|
|
91
108
|
*/
|
|
92
|
-
const SEPARATOR_CHARS = ",.' \u2019\
|
|
109
|
+
const SEPARATOR_CHARS = ",.' \u2019\u066B\u066C";
|
|
93
110
|
const NUMBER_BODY = new RegExp(`^[0-9${SEPARATOR_CHARS}]+$`);
|
|
94
111
|
/** No grouping and at most one full stop: the format `Number()` reads. */
|
|
95
112
|
const MACHINE_NUMBER = /^(?:\d+\.?\d*|\.\d+)$/;
|
|
@@ -198,7 +215,7 @@ function readUnder(
|
|
|
198
215
|
* beats the decimal-point reading.
|
|
199
216
|
*/
|
|
200
217
|
function isGroupOnlySeparator(separator: string): boolean {
|
|
201
|
-
return /^[\s'\u2019
|
|
218
|
+
return /^[\s'\u2019]$/.test(separator);
|
|
202
219
|
}
|
|
203
220
|
|
|
204
221
|
function toPlainNumber(
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Ambient module declaration for Vite's `?raw` imports.
|
|
5
|
+
*
|
|
6
|
+
* numberParser.docblock.test.ts reads its own subject's source as text, to
|
|
7
|
+
* check the prose in it against the parser's behaviour. Vite serves that with
|
|
8
|
+
* the `?raw` query; without this declaration TypeScript fails with TS2307.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
declare module '*?raw' {
|
|
12
|
+
const content: string;
|
|
13
|
+
export default content;
|
|
14
|
+
}
|