Refined InputNumber (#894)

* Refined InputNumber

* rm console

* rm console

* travis-ci

* fix unitless input, add isDecimal prop

* CI fix (linting)
This commit is contained in:
kwingram25 2019-04-07 01:46:24 -07:00 committed by Jaco Greeff
parent 915fbd0368
commit 7e27d28a8d
2 changed files with 116 additions and 99 deletions

View File

@ -32,6 +32,8 @@ type Props = BareProps & {
onBlur?: (event: React.KeyboardEvent<Element>) => void,
onKeyDown?: (event: React.KeyboardEvent<Element>) => void,
onKeyUp?: (event: React.KeyboardEvent<Element>) => void,
onKeyPress?: (event: React.KeyboardEvent<Element>) => void,
onPaste?: (event: React.ClipboardEvent<Element>) => void,
placeholder?: string,
tabIndex?: number,
type?: Input$Type,
@ -43,6 +45,12 @@ type State = {
name: string;
};
// Find decimal separator used in current locale
const getDecimalSeparator = (): string => Intl.NumberFormat()
.formatToParts(1.1)
.find(part => part.type === 'decimal')!
.value;
// note: KeyboardEvent.keyCode and KeyboardEvent.which are deprecated
const KEYS = {
A: 'a',
@ -58,7 +66,8 @@ const KEYS = {
TAB: 'Tab',
V: 'v',
X: 'x',
ZERO: '0'
ZERO: '0',
DECIMAL: getDecimalSeparator()
};
const KEYS_PRE: Array<any> = [KEYS.ALT, KEYS.CMD, KEYS.CTRL];
@ -134,6 +143,7 @@ export default class Input extends React.PureComponent<Props, State> {
? 'new-password'
: 'off'
}
onPaste={this.onPaste}
/>
{
isEditable
@ -169,6 +179,14 @@ export default class Input extends React.PureComponent<Props, State> {
onKeyUp(event);
}
}
private onPaste = (event: React.ClipboardEvent<Element>): void => {
const { onPaste } = this.props;
if (onPaste) {
onPaste(event);
}
}
}
export {

View File

@ -11,7 +11,7 @@ import { formatBalance, isUndefined } from '@polkadot/util';
import { classes } from './util';
import { BitLengthOption } from './constants';
import Dropdown from './Dropdown';
import Input, { KEYS, KEYS_PRE, isCopy, isCut, isPaste, isSelectAll } from './Input';
import Input, { KEYS, KEYS_PRE } from './Input';
import translate from './translate';
type Props = BareProps & I18nProps & {
@ -22,6 +22,7 @@ type Props = BareProps & I18nProps & {
isDisabled?: boolean,
isError?: boolean,
isSi?: boolean,
isDecimal?: boolean,
label?: any,
maxLength?: number,
onChange?: (value?: BN) => void,
@ -31,16 +32,15 @@ type Props = BareProps & I18nProps & {
};
type State = {
defaultValue?: string,
isPreKeyDown: boolean,
isValid: boolean,
siOptions: Array<{ value: string, text: string }>,
siUnit: string,
value: string,
valueBN: BN
};
const DEFAULT_BITLENGTH = BitLengthOption.NORMAL_NUMBERS as BitLength;
const KEYS_ALLOWED: Array<any> = [KEYS.ARROW_LEFT, KEYS.ARROW_RIGHT, KEYS.BACKSPACE, KEYS.ENTER, KEYS.ESCAPE, KEYS.TAB];
class InputNumber extends React.PureComponent<Props, State> {
constructor (props: Props) {
@ -48,10 +48,10 @@ class InputNumber extends React.PureComponent<Props, State> {
const { defaultValue, isSi, value } = this.props;
let valueBN = new BN(value || 0);
const si = formatBalance.calcSi(valueBN.toString());
const si = formatBalance.findSi('-');
this.state = {
defaultValue: isSi
value: isSi
? new BN(defaultValue || valueBN).div(new BN(10).pow(new BN(si.power))).toString()
: (defaultValue || valueBN).toString(),
isPreKeyDown: false,
@ -72,32 +72,26 @@ class InputNumber extends React.PureComponent<Props, State> {
InputNumber.units = units;
}
static getDerivedStateFromProps ({ isDisabled, isSi, defaultValue = '0' }: Props, state: State): Partial<State> | null {
static getDerivedStateFromProps ({ isDisabled, isSi, defaultValue = '0' }: Props): Partial<State> | null {
if (!isDisabled || !isSi) {
return null;
}
return {
defaultValue: formatBalance(defaultValue, false),
value: formatBalance(defaultValue, false),
siUnit: formatBalance.calcSi(defaultValue.toString(), formatBalance.getDefaults().decimals).value
};
}
render () {
const { bitLength = DEFAULT_BITLENGTH, className, defaultValue = '0', help, isSi, isDisabled, maxLength, style, t } = this.props;
const { isValid } = this.state;
const { bitLength = DEFAULT_BITLENGTH, className, help, isSi, isDisabled, maxLength, style, t } = this.props;
const { isValid, value } = this.state;
const maxValueLength = this.maxValue(bitLength).toString().length - 1;
const value = this.state.defaultValue || defaultValue;
return (
<Input
{...this.props}
className={classes('ui--InputNumber', className)}
defaultValue={
isDisabled
? undefined
: value
}
help={help}
isAction={isSi}
isDisabled={isDisabled}
@ -106,28 +100,20 @@ class InputNumber extends React.PureComponent<Props, State> {
onChange={this.onChange}
onKeyDown={this.onKeyDown}
onKeyUp={this.onKeyUp}
onPaste={this.onPaste}
placeholder={t('Positive number')}
style={style}
value={
isDisabled
? value
: undefined
}
value={value}
type='text'
>
{this.renderSiDropdown()}
{isSi && this.renderSiDropdown()}
</Input>
);
}
private renderSiDropdown () {
const { isSi } = this.props;
const { siOptions, siUnit } = this.state;
if (!isSi) {
return undefined;
}
return (
<Dropdown
isButton
@ -146,40 +132,22 @@ class InputNumber extends React.PureComponent<Props, State> {
return value.bitLength() <= (bitLength || DEFAULT_BITLENGTH);
}
private isValidKey = (event: React.KeyboardEvent<Element>, isPreKeyDown: boolean): boolean => {
const { value: previousValue } = event.target as HTMLInputElement;
// prevents entry of zero if initial digit is zero
const isDuplicateZero = previousValue[0] === '0' && event.key === KEYS.ZERO;
if (isDuplicateZero) {
return false;
}
// allow cut/copy/paste combinations but not non-numeric letters (i.e. a, c, x, v) individually
if (
(isSelectAll(event.key, isPreKeyDown)) ||
(isCut(event.key, isPreKeyDown)) ||
(isCopy(event.key, isPreKeyDown)) ||
(isPaste(event.key, isPreKeyDown))
) {
return true;
}
if (isNaN(Number(event.key)) && !KEYS_ALLOWED.includes(event.key)) {
private isValidNumber (input: BN, bitLength: number = DEFAULT_BITLENGTH): boolean {
const maxBN = this.maxValue(bitLength);
if (input.lt(new BN(0)) || !input.lt(maxBN) || !this.isValidBitLength(input, bitLength)) {
return false;
}
return true;
}
private isValidNumber (input: BN, bitLength: number = DEFAULT_BITLENGTH): boolean {
const maxBN = this.maxValue(bitLength);
if (!input.lt(maxBN) || !this.isValidBitLength(input, bitLength)) {
return false;
}
return true;
private regex = (): RegExp => {
const { isDecimal, isSi } = this.props;
return new RegExp(
(isSi || isDecimal) ?
`^(0|[1-9]\\d*)(\\${KEYS.DECIMAL}\\d*)?$` :
`^(0|[1-9]\\d*)$`
);
}
private onChange = (value: string): void => {
@ -187,10 +155,10 @@ class InputNumber extends React.PureComponent<Props, State> {
const { siUnit } = this.state;
try {
const valueBN = this.applySi(siUnit, new BN(value || 0));
const valueBN = this.inputValueToBn(value, siUnit);
const isValid = this.isValidNumber(valueBN, bitLength);
this.setState({ isValid, valueBN });
this.setState({ isValid, value, valueBN });
onChange && onChange(
isValid
@ -207,79 +175,110 @@ class InputNumber extends React.PureComponent<Props, State> {
if (KEYS_PRE.includes(event.key)) {
this.setState({ isPreKeyDown: true });
return;
}
// restrict input of certain keys
const isValid = this.isValidKey(event, isPreKeyDown);
if (event.key.length === 1 && !isPreKeyDown) {
const { selectionStart: i, selectionEnd: j, value } = event.target as HTMLInputElement;
const newValue = `${
value.substring(0, i!)
}${
event.key
}${
value.substring(j!)
}`;
if (!isValid) {
event.preventDefault();
if (!this.regex().test(newValue)) {
event.preventDefault();
}
}
}
private onKeyUp = (event: React.KeyboardEvent<Element>): void => {
const { value: newValue } = event.target as HTMLInputElement;
const isNewValueZero = new BN(newValue).isZero();
if (KEYS_PRE.includes(event.key)) {
this.setState({ isPreKeyDown: false });
}
/* if new value equates to '0' in BN when but it's length is >=1 (i.e. '012', '00', etc)
* then replace the input value with just '0'.
* otherwise remove the preceding zeros from the new value (i.e. '0123' -> '123')
* note: edge case glitch occurs if existing value is '0' and you 'hold down' and keep
* pasting a value of '00' after it, then sometimes when you let go the
* remaining value shown as '000' or '00000' in the UI, but it's still ok because
* the actual BN if the user submitted would still be '0', and if they then press any key
* the UI input value resets to '0'
*/
if (isNewValueZero && newValue.length >= 1) {
(event.target as HTMLInputElement).value = '0';
} else {
(event.target as HTMLInputElement).value = newValue.replace(/^0+/, '');
}
}
private applySi (siUnit: string, value: BN): BN {
const { isSi } = this.props;
private onPaste = (event: React.ClipboardEvent<Element>): void => {
const { value: newValue } = event.target as HTMLInputElement;
if (!isSi) {
return value;
if (!this.regex().test(newValue)) {
event.preventDefault();
return;
}
const si = formatBalance.findSi(siUnit);
const power = new BN(formatBalance.getDefaults().decimals + si.power);
return value.mul(new BN(10).pow(power));
}
private applyNewSi (oldSi: string, newSi: string, value: BN): BN {
const si = formatBalance.findSi(oldSi);
const power = new BN(formatBalance.getDefaults().decimals + si.power);
return this.applySi(newSi, value.div(new BN(10).pow(power)));
}
private selectSiUnit = (siUnit: string): void => {
this.setState((prevState: State) => {
const { bitLength, onChange } = this.props;
const valueBN = this.applyNewSi(prevState.siUnit, siUnit, prevState.valueBN);
const isValid = this.isValidNumber(valueBN, bitLength);
const isValid = this.isValidNumber(prevState.valueBN, bitLength);
const value = this.bnToInputValue(prevState.valueBN, siUnit);
onChange && onChange(
isValid
? valueBN
? prevState.valueBN
: undefined
);
return {
isValid,
siUnit,
valueBN
value
};
});
}
private inputValueToBn = (value: string, siUnit: string): BN => {
const { isSi } = this.props;
const basePower = isSi ? formatBalance.getDefaults().decimals : 0;
const siPower = isSi ? formatBalance.findSi(siUnit).power : 0;
const isDecimalValue = value.match(/^(\d+)\.(\d+)$/);
if (isDecimalValue) {
if (siPower - isDecimalValue[2].length < -basePower) {
return new BN(-1);
}
const div = new BN(value.replace(/\.\d*$/, ''));
const mod = new BN(value.replace(/^\d+\./, ''));
return div
.mul(new BN(10).pow(new BN(basePower + siPower)))
.add(mod.mul(new BN(10).pow((new BN(basePower + siPower - mod.toString().length)))));
} else {
return new BN(value.replace(/[^\d]/g, ''))
.mul(new BN(10).pow(new BN(basePower + siPower)));
}
}
private bnToInputValue = (bn: BN, siUnit: string): string => {
const { isSi } = this.props;
const basePower = isSi ? formatBalance.getDefaults().decimals : 0;
const siPower = isSi ? formatBalance.findSi(siUnit).power : 0;
const base = new BN(10).pow(new BN(basePower + siPower));
const zero = new BN(0);
const div = bn.div(base);
const mod = bn.mod(base);
return `${
div.gt(zero) ? div.toString() : '0'
}${
mod.gt(zero) ?
(() => {
const padding = Math.max(
mod.toString().length,
base.toString().length - div.toString().length,
bn.toString().length - div.toString().length
);
return `.${mod.toString(10, padding).replace(/0*$/, '')}`;
})() :
''
}`;
}
}
export {