]> git.wincent.com - hextrapolate.git/blob - src/Field.react.js
6e7836e2a34cfbdb69c4edb3beccd4a77d140a08
[hextrapolate.git] / src / Field.react.js
1 /**
2  * Copyright 2003-present Greg Hurrell. All rights reserved.
3  * Licensed under the terms of the MIT license.
4  *
5  * @flow
6  */
7
8 import DIGITS from './DIGITS';
9 import PropTypes from 'prop-types';
10 import React from 'react';
11 import convert from './convert';
12 import cx from 'classnames';
13
14 export type Value = {
15   base: number;
16   value: string;
17 };
18 export const ValuePropType = PropTypes.shape({
19   base: PropTypes.number,
20   value: PropTypes.string,
21 });
22
23 /**
24  * Convert from `value` to `base`.
25  */
26 function fromValue(value: ?Value, base: number): string {
27   if (value === null) {
28     return '';
29   } else if (value.base === base) {
30     return value.value;
31   }
32   return convert(value.value, value.base, base);
33 }
34
35 function getValidator(base) {
36   const prefix = base === 16 ? '(?:0x)?' : '';
37   const regexp = new RegExp(
38     `^\\s*${prefix}[${DIGITS.slice(0, base)}]*\\s*$`,
39     'i'
40   );
41   return value => regexp.test(value);
42 }
43
44 export default class Field extends React.Component {
45   static propTypes = {
46     base: PropTypes.number,
47     onValueChange: PropTypes.func.isRequired,
48     value: ValuePropType,
49   };
50   static defaultProps = {
51     base: 10,
52   };
53
54   constructor(props) {
55     super(props);
56     if (props.base < 2 || props.base > DIGITS.length) {
57       throw new Error(
58         `base prop must be between 2..${DIGITS.length}`
59       );
60     }
61     this._validate = getValidator(props.base);
62     const value = fromValue(props.value, props.base);
63     this.state = {
64       copySucceeded: false,
65       selectionEnd: value.length,
66       selectionStart: value.length,
67       value,
68     };
69   }
70
71   componentDidUpdate(prevProps, prevState) {
72     const {selectionStart, selectionEnd} = this.state;
73     this._input.setSelectionRange(selectionStart, selectionEnd);
74   }
75
76   componentWillReceiveProps(nextProps) {
77     if (nextProps.base !== this.props.base) {
78       this._validate = getValidator(nextProps.base);
79     }
80     this.setState({value: fromValue(nextProps.value, nextProps.base)});
81   }
82
83   _onChange = event => {
84     const value = event.currentTarget.value;
85     if (this._validate(value)) {
86       const {selectionEnd, selectionStart} = event.currentTarget;
87       this.setState({selectionEnd, selectionStart});
88       this.props.onValueChange({
89         base: this.props.base,
90         value,
91       });
92     } else {
93       this.setState({
94         selectionEnd: this._selectionEnd,
95         selectionStart: this._selectionStart,
96       });
97     }
98   }
99
100   _onSelect = event => {
101     // Remember selection to stop React moving cursor to end:
102     // https://github.com/facebook/react/issues/955
103     const {selectionEnd, selectionStart} = event.currentTarget;
104     this._selectionStart = selectionStart;
105     this._selectionEnd = selectionEnd;
106   }
107
108   _onCopy = () => {
109     this._input.select();
110
111     // May throw a SecurityError.
112     try {
113       this.setState({
114         copySucceeded: document.execCommand('copy'),
115       });
116       setTimeout(() => this.setState({copySucceeded: false}), 750);
117     } catch(error) {
118       this.setState({copySucceeded: false});
119     }
120   }
121
122   _copyLink() {
123     // Would check `document.queryCommandSupported('copy')` here, but that
124     // doesn't work; see:
125     // - https://code.google.com/p/chromium/issues/detail?id=476508
126     // - https://github.com/w3c/clipboard-apis/issues/4
127     return (
128       <span
129         className="hextrapolate-copy"
130         onClick={this._onCopy}
131         title="Copy to Clipboard">
132         copy
133       </span>
134     );
135   }
136
137   _copyStatus() {
138     const classNames = cx({
139       'hextrapolate-copy-status': true,
140       'hextrapolate-copy-success': this.state.copySucceeded,
141     });
142     return <span className={classNames}>&#x2713;</span>;
143   }
144
145   focus() {
146     this._input.focus();
147   }
148
149   render() {
150     return (
151       <span className="hextrapolate-field">
152         <input
153           onChange={this._onChange}
154           onSelect={this._onSelect}
155           ref={ref => this._input = ref}
156           spellCheck="false"
157           type="text"
158           value={this.state.value}
159         />
160         {this._copyStatus()}
161         {this._copyLink()}
162       </span>
163     );
164   }
165 }