1 | /*
|
2 | * Copyright 2018 Palantir Technologies, Inc. All rights reserved.
|
3 | *
|
4 | * Licensed under the Apache License, Version 2.0 (the "License");
|
5 | * you may not use this file except in compliance with the License.
|
6 | * You may obtain a copy of the License at
|
7 | *
|
8 | * http://www.apache.org/licenses/LICENSE-2.0
|
9 | *
|
10 | * Unless required by applicable law or agreed to in writing, software
|
11 | * distributed under the License is distributed on an "AS IS" BASIS,
|
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13 | * See the License for the specific language governing permissions and
|
14 | * limitations under the License.
|
15 | */
|
16 |
|
17 | import { clamp } from "../../common/utils";
|
18 |
|
19 | /** Returns the `decimal` number separator based on locale */
|
20 | function getDecimalSeparator(locale: string) {
|
21 | const testNumber = 1.9;
|
22 | const testText = testNumber.toLocaleString(locale);
|
23 | const one = (1).toLocaleString(locale);
|
24 | const nine = (9).toLocaleString(locale);
|
25 | const pattern = `${one}(.+)${nine}`;
|
26 |
|
27 | const result = new RegExp(pattern).exec(testText);
|
28 |
|
29 | return (result && result[1]) || ".";
|
30 | }
|
31 |
|
32 | export function toLocaleString(num: number, locale: string = "en-US") {
|
33 | return sanitizeNumericInput(num.toLocaleString(locale), locale);
|
34 | }
|
35 |
|
36 | export function clampValue(value: number, min?: number, max?: number) {
|
37 | // defaultProps won't work if the user passes in null, so just default
|
38 | // to +/- infinity here instead, as a catch-all.
|
39 | const adjustedMin = min != null ? min : -Infinity;
|
40 | const adjustedMax = max != null ? max : Infinity;
|
41 | return clamp(value, adjustedMin, adjustedMax);
|
42 | }
|
43 |
|
44 | export function getValueOrEmptyValue(value: number | string = "") {
|
45 | return value.toString();
|
46 | }
|
47 |
|
48 | /** Transform the localized character (ex. "") to a javascript recognizable string number (ex. "10.99") */
|
49 | function transformLocalizedNumberToStringNumber(character: string, locale: string) {
|
50 | const charactersMap = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(value => value.toLocaleString(locale));
|
51 | const jsNumber = charactersMap.indexOf(character);
|
52 |
|
53 | if (jsNumber !== -1) {
|
54 | return jsNumber;
|
55 | } else {
|
56 | return character;
|
57 | }
|
58 | }
|
59 |
|
60 | /** Transforms the localized number (ex. "10,99") to a javascript recognizable string number (ex. "10.99") */
|
61 | export function parseStringToStringNumber(value: number | string, locale: string | undefined): string {
|
62 | const valueAsString = "" + value;
|
63 | if (parseFloat(valueAsString).toString() === value.toString()) {
|
64 | return value.toString();
|
65 | }
|
66 |
|
67 | if (locale !== undefined) {
|
68 | const decimalSeparator = getDecimalSeparator(locale);
|
69 | const sanitizedString = sanitizeNumericInput(valueAsString, locale);
|
70 |
|
71 | return sanitizedString
|
72 | .split("")
|
73 | .map(character => transformLocalizedNumberToStringNumber(character, locale))
|
74 | .join("")
|
75 | .replace(decimalSeparator, ".");
|
76 | }
|
77 |
|
78 | return value.toString();
|
79 | }
|
80 |
|
81 | /** Returns `true` if the string represents a valid numeric value, like "1e6". */
|
82 | export function isValueNumeric(value: string, locale: string | undefined) {
|
83 | // checking if a string is numeric in Typescript is a big pain, because
|
84 | // we can't simply toss a string parameter to isFinite. below is the
|
85 | // essential approach that jQuery uses, which involves subtracting a
|
86 | // parsed numeric value from the string representation of the value. we
|
87 | // need to cast the value to the `any` type to allow this operation
|
88 | // between dissimilar types.
|
89 | const stringToStringNumber = parseStringToStringNumber(value, locale);
|
90 | return value != null && (stringToStringNumber as any) - parseFloat(stringToStringNumber) + 1 >= 0;
|
91 | }
|
92 |
|
93 | export function isValidNumericKeyboardEvent(e: React.KeyboardEvent, locale: string | undefined) {
|
94 | // unit tests may not include e.key. don't bother disabling those events.
|
95 | if (e.key == null) {
|
96 | return true;
|
97 | }
|
98 |
|
99 | // allow modified key strokes that may involve letters and other
|
100 | // non-numeric/invalid characters (Cmd + A, Cmd + C, Cmd + V, Cmd + X).
|
101 | if (e.ctrlKey || e.altKey || e.metaKey) {
|
102 | return true;
|
103 | }
|
104 |
|
105 | // keys that print a single character when pressed have a `key` name of
|
106 | // length 1. every other key has a longer `key` name (e.g. "Backspace",
|
107 | // "ArrowUp", "Shift"). since none of those keys can print a character
|
108 | // to the field--and since they may have important native behaviors
|
109 | // beyond printing a character--we don't want to disable their effects.
|
110 | const isSingleCharKey = e.key.length === 1;
|
111 | if (!isSingleCharKey) {
|
112 | return true;
|
113 | }
|
114 |
|
115 | // now we can simply check that the single character that wants to be printed
|
116 | // is a floating-point number character that we're allowed to print.
|
117 | return isFloatingPointNumericCharacter(e.key, locale);
|
118 | }
|
119 |
|
120 | /**
|
121 | * A regex that matches a string of length 1 (i.e. a standalone character)
|
122 | * if and only if it is a floating-point number character as defined by W3C:
|
123 | * https://www.w3.org/TR/2012/WD-html-markup-20120329/datatypes.html#common.data.float
|
124 | *
|
125 | * Floating-point number characters are the only characters that can be
|
126 | * printed within a default input[type="number"]. This component should
|
127 | * behave the same way when this.props.allowNumericCharactersOnly = true.
|
128 | * See here for the input[type="number"].value spec:
|
129 | * https://www.w3.org/TR/2012/WD-html-markup-20120329/input.number.html#input.number.attrs.value
|
130 | */
|
131 | function isFloatingPointNumericCharacter(character: string, locale: string | undefined) {
|
132 | if (locale !== undefined) {
|
133 | const decimalSeparator = getDecimalSeparator(locale).replace(".", "\\.");
|
134 | const numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(value => value.toLocaleString(locale)).join("");
|
135 | const localeFloatingPointNumericCharacterRegex = new RegExp(
|
136 | "^[Ee" + numbers + "\\+\\-" + decimalSeparator + "]$",
|
137 | );
|
138 |
|
139 | return localeFloatingPointNumericCharacterRegex.test(character);
|
140 | } else {
|
141 | const floatingPointNumericCharacterRegex = /^[Ee0-9\+\-\.]$/;
|
142 |
|
143 | return floatingPointNumericCharacterRegex.test(character);
|
144 | }
|
145 | }
|
146 |
|
147 | /**
|
148 | * Round the value to have _up to_ the specified maximum precision.
|
149 | *
|
150 | * This differs from `toFixed(5)` in that trailing zeroes are not added on
|
151 | * more precise values, resulting in shorter strings.
|
152 | */
|
153 | export function toMaxPrecision(value: number, maxPrecision: number) {
|
154 | // round the value to have the specified maximum precision (toFixed is the wrong choice,
|
155 | // because it would show trailing zeros in the decimal part out to the specified precision)
|
156 | // source: http://stackoverflow.com/a/18358056/5199574
|
157 | const scaleFactor = Math.pow(10, maxPrecision);
|
158 | return Math.round(value * scaleFactor) / scaleFactor;
|
159 | }
|
160 |
|
161 | /**
|
162 | * Convert Japanese full-width numbers, e.g. '5', to ASCII, e.g. '5'
|
163 | * This should be called before performing any other numeric string input validation.
|
164 | */
|
165 | function convertFullWidthNumbersToAscii(value: string) {
|
166 | return value.replace(/[\uFF10-\uFF19]/g, m => String.fromCharCode(m.charCodeAt(0) - 0xfee0));
|
167 | }
|
168 |
|
169 | /**
|
170 | * Convert full-width (Japanese) numbers to ASCII, and strip all characters that are not valid floating-point numeric characters
|
171 | */
|
172 | export function sanitizeNumericInput(value: string, locale: string | undefined) {
|
173 | const valueChars = convertFullWidthNumbersToAscii(value).split("");
|
174 | const sanitizedValueChars = valueChars.filter(valueChar => isFloatingPointNumericCharacter(valueChar, locale));
|
175 |
|
176 | return sanitizedValueChars.join("");
|
177 | }
|