summaryrefslogtreecommitdiffhomepage
path: root/app/components/AccountInput.js
diff options
context:
space:
mode:
Diffstat (limited to 'app/components/AccountInput.js')
-rw-r--r--app/components/AccountInput.js317
1 files changed, 317 insertions, 0 deletions
diff --git a/app/components/AccountInput.js b/app/components/AccountInput.js
new file mode 100644
index 0000000000..4ff99e13fd
--- /dev/null
+++ b/app/components/AccountInput.js
@@ -0,0 +1,317 @@
+// @flow
+import React, { Component } from 'react';
+import { formatAccount } from '../lib/formatters';
+
+// @TODO: move it into types.js
+
+// ESLint issue: https://github.com/babel/babel-eslint/issues/445
+declare class ClipboardData { // eslint-disable-line no-unused-vars
+ setData(type: string, data: string): void;
+ getData(type: string): string;
+}
+
+declare class ClipboardEvent extends Event {
+ clipboardData: ClipboardData;
+}
+
+export type AccountInputProps = {
+ value: string;
+ onEnter: ?(() => void);
+ onChange: ?((newValue: string) => void);
+};
+
+export type AccountInputState = {
+ value: string;
+ selectionRange: SelectionRange;
+};
+
+export type SelectionRange = [number, number];
+
+export default class AccountInput extends Component {
+ props: AccountInputProps;
+
+ static defaultProps: AccountInputProps = {
+ value: '',
+ onEnter: null,
+ onChange: null
+ };
+
+ state: AccountInputState = {
+ value: '',
+ selectionRange: [0, 0]
+ };
+
+ _ref: ?HTMLInputElement;
+ _ignoreSelect = false;
+
+ constructor(props: AccountInputProps) {
+ super(props);
+
+ // selection range holds selection converted from DOM selection range to
+ // internal unformatted representation of account number
+ const val = this.sanitize(props.value);
+
+ this.state = {
+ value: val,
+ selectionRange: [val.length, val.length]
+ };
+ }
+
+ componentWillReceiveProps(nextProps: AccountInputProps) {
+ const nextVal = this.sanitize(nextProps.value);
+ if(nextVal !== this.state.value) {
+ const len = nextVal.length;
+ this.setState({ value: nextVal, selectionRange: [len, len] });
+ }
+ }
+
+ shouldComponentUpdate(nextProps: AccountInputProps, nextState: AccountInputState) {
+ return (this.props.value !== nextProps.value ||
+ this.props.onEnter !== nextProps.onEnter ||
+ this.props.onChange !== nextProps.onChange ||
+ this.state.value !== nextState.value ||
+ this.state.selectionRange[0] !== nextState.selectionRange[0] ||
+ this.state.selectionRange[1] !== nextState.selectionRange[1]);
+ }
+
+ render() {
+ const displayString = formatAccount(this.state.value || '');
+ const { value, onChange, onEnter, ...otherProps } = this.props; // eslint-disable-line no-unused-vars
+ return (
+ <input { ...otherProps }
+ type="text"
+ value={ displayString }
+ onChange={ () => {} }
+ onSelect={ this.onSelect }
+ onKeyUp={ this.onKeyUp }
+ onKeyDown={ this.onKeyDown }
+ onPaste={ this.onPaste }
+ onCut={ this.onCut }
+ ref={ this.onRef } />
+ );
+ }
+
+ // Private
+
+ /**
+ * Modify original string inserting substring using selection range
+ */
+ sanitize(val: ?string): string {
+ return (val || '').replace(/[^0-9]/g, '');
+ }
+
+ /**
+ * Modify original string inserting substring using selection range
+ *
+ * @private
+ * @param {String} val original string
+ * @param {String} insert insertion string
+ * @param {Array} selRange selection range ([x,y])
+ * @returns {Object}
+ */
+ insert(val: string, insert: string, selRange: SelectionRange): AccountInputState {
+ const head = val.slice(0, selRange[0]);
+ const tail = val.slice(selRange[1], val.length);
+ const newVal = head + insert + tail;
+ const selectionOffset = head.length + insert.length;
+
+ return { value: newVal, selectionRange: [selectionOffset, selectionOffset] };
+ }
+
+
+ /**
+ * Modify string by removing single character or range of characters based on selection range.
+ *
+ * @private
+ * @param {String} val original string
+ * @param {Array} selRange selection range ([x,y])
+ * @returns {Object}
+ *
+ * @memberOf AccountInput
+ */
+ remove(val: string, selRange: SelectionRange): AccountInputState {
+ let newVal, selectionOffset;
+
+ if(selRange[0] === selRange[1]) {
+ const oneOff = Math.max(0, selRange[0] - 1);
+ const head = val.slice(0, oneOff);
+ const tail = val.slice(selRange[0], val.length);
+ newVal = head + tail;
+ selectionOffset = head.length;
+ } else {
+ const head = val.slice(0, selRange[0]);
+ const tail = val.slice(selRange[1], val.length);
+ newVal = head + tail;
+ selectionOffset = head.length;
+ }
+
+ return { value: newVal, selectionRange: [selectionOffset, selectionOffset] };
+ }
+
+
+ /**
+ * Convert DOM selection range to internal selection range
+ *
+ * @private
+ * @param {String} val original string
+ * @param {Array} domRange selection range from DOM
+ * @returns {Object}
+ *
+ * @memberOf AccountInput
+ */
+ toInternalSelectionRange(val: string, domRange: SelectionRange): SelectionRange {
+ const countSpaces = (val) => {
+ return (val.match(/\s/g) || []).length;
+ };
+
+ const fmt = formatAccount(val || '');
+ let start = domRange[0];
+ let end = domRange[1];
+ const before = countSpaces(fmt.slice(0, start));
+ const within = countSpaces(fmt.slice(start, end));
+
+ start -= before;
+ end -= (before + within);
+
+ return [ start, end ];
+ }
+
+
+ /**
+ * Convert internal selection range to DOM selection range
+ *
+ * @private
+ * @param {String} val original string
+ * @param {Array} selRange selection range
+ * @returns {Object}
+ *
+ * @memberOf AccountInput
+ */
+ toDomSelection(val: string, selRange: SelectionRange): SelectionRange {
+ const countSpaces = (val, untilIndex) => {
+ if(val.length > 12) { return 0; }
+ return Math.floor(untilIndex / 4); // groups of 4 digits
+ };
+
+ let start = selRange[0];
+ let end = selRange[1];
+ const startSpaces = countSpaces(val, start);
+ const endSpaces = countSpaces(val, end);
+
+ start += startSpaces;
+ end += startSpaces + (endSpaces - startSpaces);
+
+ return [ start, end ];
+ }
+
+ // Events
+
+ onKeyDown = (e: KeyboardEvent) => {
+ const { value, selectionRange } = this.state;
+
+ if(e.which === 8) { // backspace
+ const result = this.remove(value, selectionRange);
+ e.preventDefault();
+
+ this._ignoreSelect = true;
+
+ this.setState(result, () => {
+ if(this.props.onChange) {
+ this.props.onChange(result.value);
+ }
+ });
+ } else if(/^[0-9]$/.test(e.key)) { // digits or cmd+v
+ const result = this.insert(value, e.key, selectionRange);
+ e.preventDefault();
+
+ this._ignoreSelect = true;
+
+ this.setState(result, () => {
+ if(this.props.onChange) {
+ this.props.onChange(result.value);
+ }
+ });
+ }
+ }
+
+ onKeyUp = (e: KeyboardEvent) => {
+ this._ignoreSelect = false;
+
+ if(e.which === 13 && this.props.onEnter) {
+ this.props.onEnter();
+ }
+ }
+
+ onSelect = (e: Event) => {
+ const ref = e.target;
+ if(!(ref instanceof HTMLInputElement)) {
+ throw new Error('ref must be an instance of HTMLInputElement');
+ }
+
+ if(this._ignoreSelect) {
+ return;
+ }
+
+ const start = ref.selectionStart;
+ const end = ref.selectionEnd;
+ const selRange = this.toInternalSelectionRange(this.sanitize(ref.value), [start, end]);
+ this.setState({ selectionRange: selRange });
+ }
+
+ onPaste = (e: ClipboardEvent) => {
+ const { value, selectionRange } = this.state;
+ const pastedData = e.clipboardData.getData('text');
+ const filteredData = this.sanitize(pastedData);
+ const result = this.insert(value, filteredData, selectionRange);
+ e.preventDefault();
+ this.setState(result, () => {
+ if(this.props.onChange) {
+ this.props.onChange(result.value);
+ }
+ });
+ }
+
+ onCut = (e: ClipboardEvent) => {
+ const target = e.target;
+ if(!(target instanceof HTMLInputElement)) {
+ throw new Error('ref must be an instance of HTMLInputElement');
+ }
+
+ const { value, selectionRange } = this.state;
+
+ e.preventDefault();
+
+ // range is not empty?
+ if(selectionRange[0] !== selectionRange[1]) {
+ const result = this.remove(value, selectionRange);
+ const domSelectionRange = this.toDomSelection(value, selectionRange);
+ const slice = target.value.slice(domSelectionRange[0], domSelectionRange[1]);
+
+ e.clipboardData.setData('text', slice);
+
+ this.setState(result, () => {
+ if(this.props.onChange) {
+ this.props.onChange(result.value);
+ }
+ });
+ }
+ }
+
+ onRef = (ref: HTMLInputElement) => {
+ this._ref = ref;
+ if(!ref) { return; }
+
+ const { value, selectionRange } = this.state;
+ const domRange = this.toDomSelection(value, selectionRange);
+
+ ref.selectionStart = domRange[0];
+ ref.selectionEnd = domRange[1];
+ }
+
+ focus() {
+ if(this._ref) {
+ this._ref.focus();
+ }
+ }
+
+} \ No newline at end of file