New Address Selector Component (#3829)

* WIP new address selector

* WIP Working New Account Selector

* WIP Fully working Addres Selector (missing keyboards navigation)

* WIP Address Selector

* Fully functionnal new Address Selector!

* Implement disabled prop

* Don't auto-open on focus + Text Selection fix

* Add copy address capabilities

* Better Address Selector Focus

* Search from tags

* [Address Selector] Better Focus // Parity Background

* Linting

* [Adress Selector] Better focused input style

* Better focus support for inputs

* Fix style issue

* Add tags to accounts

* linting

* Add label to address selector

* Removed old address selector + improved styling

* Fixing address selection issues

* fix test

* Better logs...

* PR Grumbles Part 1

* PR Grumbles Part 2

* PR Grumbles Part 3.1

* PR Grumbles Part 3.2

* PR Grumbles Part 3.3

* New Portal Component

* Simpler Transition for Portal

* PR Grumbles Part 4

* Align font-family with rest of UI

* Fix null value input

* Fix Webpack build...
This commit is contained in:
Nicolas Gotchac
2016-12-27 10:59:37 +01:00
committed by Gav Wood
parent 4e51f93176
commit 1ffc6ac58c
25 changed files with 1289 additions and 312 deletions

View File

@@ -14,53 +14,110 @@
/* You should have received a copy of the GNU General Public License
/* along with Parity. If not, see <http://www.gnu.org/licenses/>.
*/
.account {
padding: 0.25em 0;
display: flex;
align-items: center;
}
.name {
height: 32px;
line-height: 32px;
display: inline-block;
vertical-align: top;
text-transform: uppercase;
padding: 0 0 0 1em;
}
.input {
box-sizing: border-box;
appearance: textfield;
width: 100%;
padding: 0;
border: none;
background: transparent;
.balance {
color: #aaa;
padding-left: 1em;
}
transition-property: font-size, padding;
transition-duration: 0.5s;
transition-timing-function: cubic-bezier(0.7,0,0.3,1);
.image {
display: inline-block;
height: 32px;
width: 32px;
margin: 0;
z-index: 10;
}
color: white;
font-family: inherit;
font-size: 2em;
.icon {
position: absolute;
left: 0;
top: 35px;
&:focus {
outline: none;
}
&.noLabel {
top: 11px;
&::placeholder {
color: #a2a2a2;
}
}
.paddedInput input {
padding-left: 46px !important;
}
.container {
.inputAddress {
position: relative;
&:hover, *:hover {
cursor: text !important;
}
}
.menuItem {
min-height: 0 !important;
line-height: inherit !important;
.main {
position: relative;
left: 0;
&:focus {
outline: none;
}
}
.label {
margin: 1rem 2.5rem 0.25em;
color: rgba(255, 255, 255, 0.498039);
}
.underline {
position: relative;
margin: 0 9rem 0 2.5rem;
}
.empty {
font-size: 1.5em;
}
.inputContainer {
display: flex;
flex-direction: column;
flex: 1;
.input {
font-size: 1.5em;
padding: 0 9rem 0.5em 2.5rem;
display: block;
padding-right: 6rem;
}
}
.categories {
flex: 1;
display: flex;
flex-direction: row;
justify-content: flex-start;
margin: 2rem 2rem 0;
> * {
flex: 1;
}
}
.category {
display: flex;
flex-direction: column;
margin: 0 0.5em;
max-width: 35em;
.title {
text-transform: uppercase;
font-size: 1.5em;
font-color: white;
}
.cards {
flex: 1;
overflow: auto;
display: flex;
flex-direction: column;
margin: 1em 0;
}
}

View File

@@ -14,277 +14,599 @@
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
import { isEqual, pick } from 'lodash';
import { MenuItem } from 'material-ui';
import React, { Component, PropTypes } from 'react';
import ReactDOM from 'react-dom';
import { connect } from 'react-redux';
import keycode, { codes } from 'keycode';
import { FormattedMessage } from 'react-intl';
import { fromWei } from '~/api/util/wei';
import { nodeOrStringProptype } from '~/util/proptypes';
import TextFieldUnderline from 'material-ui/TextField/TextFieldUnderline';
import AutoComplete from '../AutoComplete';
import IdentityIcon from '../../IdentityIcon';
import IdentityName from '../../IdentityName';
import AccountCard from '~/ui/AccountCard';
import InputAddress from '~/ui/Form/InputAddress';
import Portal from '~/ui/Portal';
import { validateAddress } from '~/util/validation';
import styles from './addressSelect.css';
export default class AddressSelect extends Component {
const BOTTOM_BORDER_STYLE = { borderBottom: 'solid 3px' };
// Current Form ID
let currentId = 1;
class AddressSelect extends Component {
static contextTypes = {
api: PropTypes.object.isRequired
}
muiTheme: PropTypes.object.isRequired
};
static propTypes = {
// Required props
onChange: PropTypes.func.isRequired,
// Redux props
accountsInfo: PropTypes.object,
accounts: PropTypes.object,
allowInput: PropTypes.bool,
balances: PropTypes.object,
contacts: PropTypes.object,
contracts: PropTypes.object,
disabled: PropTypes.bool,
error: nodeOrStringProptype(),
hint: nodeOrStringProptype(),
label: nodeOrStringProptype(),
tokens: PropTypes.object,
value: PropTypes.string,
wallets: PropTypes.object
}
wallets: PropTypes.object,
// Optional props
allowInput: PropTypes.bool,
disabled: PropTypes.bool,
error: PropTypes.string,
hint: PropTypes.string,
label: PropTypes.string,
value: PropTypes.string
};
static defaultProps = {
value: ''
};
state = {
autocompleteEntries: [],
entries: {},
addresses: [],
value: ''
}
// Cache autocomplete items
items = {}
entriesFromProps (props = this.props) {
const { accounts = {}, contacts = {}, contracts = {}, wallets = {} } = props;
const autocompleteEntries = [].concat(
Object.values(wallets),
'divider',
Object.values(accounts),
'divider',
Object.values(contacts),
'divider',
Object.values(contracts)
);
const entries = {
...wallets,
...accounts,
...contacts,
...contracts
};
return { autocompleteEntries, entries };
}
shouldComponentUpdate (nextProps, nextState) {
const keys = [ 'error', 'value' ];
const prevValues = pick(this.props, keys);
const nextValues = pick(nextProps, keys);
return !isEqual(prevValues, nextValues);
}
expanded: false,
focused: false,
focusedCat: null,
focusedItem: null,
inputFocused: false,
inputValue: '',
values: []
};
componentWillMount () {
const { value } = this.props;
const { entries, autocompleteEntries } = this.entriesFromProps();
const addresses = Object.keys(entries).sort();
this.setState({ autocompleteEntries, entries, addresses, value });
this.setValues();
}
componentWillReceiveProps (newProps) {
if (newProps.value !== this.props.value) {
this.setState({ value: newProps.value });
componentWillReceiveProps (nextProps) {
if (this.values && this.values.length > 0) {
return;
}
this.setValues(nextProps);
}
setValues (props = this.props) {
const { accounts = {}, contracts = {}, contacts = {}, wallets = {} } = props;
const accountsN = Object.keys(accounts).length;
const contractsN = Object.keys(contracts).length;
const contactsN = Object.keys(contacts).length;
const walletsN = Object.keys(wallets).length;
if (accountsN + contractsN + contactsN + walletsN === 0) {
return;
}
this.values = [
{
label: 'accounts',
values: [].concat(
Object.values(wallets),
Object.values(accounts)
)
},
{
label: 'contacts',
values: Object.values(contacts)
},
{
label: 'contracts',
values: Object.values(contracts)
}
].filter((cat) => cat.values.length > 0);
this.handleChange();
}
render () {
const { allowInput, disabled, error, hint, label } = this.props;
const { autocompleteEntries, value } = this.state;
const input = this.renderInput();
const content = this.renderContent();
const searchText = this.getSearchText();
const icon = this.renderIdentityIcon(value);
const classes = [ styles.main ];
return (
<div className={ styles.container }>
<AutoComplete
className={ !icon ? '' : styles.paddedInput }
disabled={ disabled }
entries={ autocompleteEntries }
entry={ this.getEntry() || {} }
error={ error }
filter={ this.handleFilter }
hint={
<FormattedMessage
id='ui.addressSelect.search.hint'
defaultMessage='search for {hint}'
values={ {
hint: hint ||
<FormattedMessage
id='ui.addressSelect.search.address'
defaultMessage='address' />
} } />
}
label={ label }
onBlur={ this.onBlur }
onChange={ this.onChange }
onUpdateInput={ allowInput && this.onUpdateInput }
renderItem={ this.renderItem }
value={ searchText } />
{ icon }
</div>
);
}
renderIdentityIcon (inputValue) {
const { error, value, label } = this.props;
if (error || !inputValue || value.length !== 42) {
return null;
}
const classes = [ styles.icon ];
if (!label) {
classes.push(styles.noLabel);
}
return (
<IdentityIcon
address={ value }
center
<div
className={ classes.join(' ') }
inline />
onBlur={ this.handleMainBlur }
onClick={ this.handleFocus }
onFocus={ this.handleMainFocus }
onKeyDown={ this.handleInputAddresKeydown }
ref='inputAddress'
tabIndex={ 0 }
>
{ input }
{ content }
</div>
);
}
renderItem = (entry) => {
const { address, name } = entry;
renderInput () {
const { focused } = this.state;
const { accountsInfo, disabled, error, hint, label, value } = this.props;
const _balance = this.getBalance(address);
const balance = _balance ? _balance.toNumber() : _balance;
const input = (
<InputAddress
accountsInfo={ accountsInfo }
allowCopy={ false }
disabled={ disabled }
error={ error }
hint={ hint }
focused={ focused }
label={ label }
readOnly
tabIndex={ -1 }
text
value={ value }
/>
);
if (!this.items[address] || this.items[address].balance !== balance) {
this.items[address] = {
address,
balance,
text: name && name.toUpperCase() || address,
value: this.renderMenuItem(address)
};
if (disabled) {
return input;
}
return this.items[address];
return (
<div className={ styles.inputAddress }>
{ input }
</div>
);
}
getBalance (address) {
const { balances = {} } = this.props;
renderContent () {
const { muiTheme } = this.context;
const { hint, disabled, label } = this.props;
const { expanded, inputFocused } = this.state;
if (disabled) {
return null;
}
const id = `addressSelect_${++currentId}`;
return (
<Portal
className={ styles.inputContainer }
onClose={ this.handleClose }
onKeyDown={ this.handleKeyDown }
open={ expanded }
>
<label className={ styles.label } htmlFor={ id }>
{ label }
</label>
<input
id={ id }
className={ styles.input }
placeholder={ hint }
onBlur={ this.handleInputBlur }
onFocus={ this.handleInputFocus }
onChange={ this.handleChange }
ref={ this.setInputRef }
/>
<div className={ styles.underline }>
<TextFieldUnderline
focus={ inputFocused }
focusStyle={ BOTTOM_BORDER_STYLE }
muiTheme={ muiTheme }
style={ BOTTOM_BORDER_STYLE }
/>
</div>
{ this.renderCurrentInput() }
{ this.renderAccounts() }
</Portal>
);
}
renderCurrentInput () {
const { inputValue } = this.state;
if (!this.props.allowInput || !inputValue) {
return null;
}
const { address, addressError } = validateAddress(inputValue);
if (addressError) {
return null;
}
return (
<div>
{ this.renderAccountCard({ address }) }
</div>
);
}
renderAccounts () {
const { values } = this.state;
if (values.length === 0) {
return (
<div className={ styles.categories }>
<div className={ styles.empty }>
<FormattedMessage
id='addressSelect.noAccount'
defaultMessage='No account matches this query...'
/>
</div>
</div>
);
}
const categories = values.map((category) => {
return this.renderCategory(category.label, category.values);
});
return (
<div className={ styles.categories }>
{ categories }
</div>
);
}
renderCategory (name, values = []) {
let content;
if (values.length === 0) {
content = (
<p>
<FormattedMessage
id='addressSelect.noAccount'
defaultMessage='No account matches this query...'
/>
</p>
);
} else {
const cards = values
.map((account) => this.renderAccountCard(account));
content = (
<div className={ styles.cards }>
<div>{ cards }</div>
</div>
);
}
return (
<div className={ styles.category } key={ name }>
<div className={ styles.title }>{ name }</div>
{ content }
</div>
);
}
renderAccountCard (_account) {
const { balances, accountsInfo } = this.props;
const { address, index = null } = _account;
const balance = balances[address];
if (!balance) {
return null;
}
const ethToken = balance.tokens.find((tok) => tok.token && tok.token.tag && tok.token.tag.toLowerCase() === 'eth');
if (!ethToken) {
return null;
}
return ethToken.value;
}
renderBalance (address) {
const balance = this.getBalance(address) || 0;
const value = fromWei(balance);
const account = {
...accountsInfo[address],
address, index
};
return (
<div className={ styles.balance }>
{ value.toFormat(3) }<small> { 'ETH' }</small>
</div>
<AccountCard
account={ account }
balance={ balance }
key={ `account_${index}` }
onClick={ this.handleClick }
onFocus={ this.focusItem }
ref={ `account_${index}` }
/>
);
}
renderMenuItem (address) {
const balance = this.props.balances
? this.renderBalance(address)
: null;
const item = (
<div className={ styles.account }>
<IdentityIcon
address={ address }
center
className={ styles.image }
inline />
<IdentityName
address={ address }
className={ styles.name } />
{ balance }
</div>
);
return (
<MenuItem
className={ styles.menuItem }
key={ address }
label={ item }
value={ address }>
{ item }
</MenuItem>
);
setInputRef = (refId) => {
this.inputRef = refId;
}
getSearchText () {
const entry = this.getEntry();
return entry && entry.name
? entry.name.toUpperCase()
: this.state.value;
}
getEntry () {
const { entries, value } = this.state;
return value ? entries[value] : null;
}
handleFilter = (searchText, name, item) => {
const { address } = item;
const entry = this.state.entries[address];
const lowCaseSearch = (searchText || '').toLowerCase();
return [entry.name, entry.address]
.some(text => text.toLowerCase().indexOf(lowCaseSearch) !== -1);
}
onChange = (entry, empty) => {
handleCustomInput = () => {
const { allowInput } = this.props;
const { value } = this.state;
const { inputValue, values } = this.state;
const address = entry && entry.address
? entry.address
: ((empty && !allowInput) ? '' : value);
// If input is HEX and allowInput === true, send it
if (allowInput && inputValue && /^(0x)?([0-9a-f])+$/i.test(inputValue)) {
return this.handleClick(inputValue);
}
this.props.onChange(null, address);
// If only one value, select it
if (values.length === 1 && values[0].values.length === 1) {
const value = values[0].values[0];
return this.handleClick(value.address);
}
}
onUpdateInput = (query, choices) => {
const { api } = this.context;
handleInputAddresKeydown = (event) => {
const code = keycode(event);
const address = query.trim();
// Simulate click on input address if enter is pressed
if (code === 'enter') {
return this.handleDOMAction('inputAddress', 'click');
}
}
if (!/^0x/.test(address) && api.util.isAddressValid(`0x${address}`)) {
const checksumed = api.util.toChecksumAddress(`0x${address}`);
return this.props.onChange(null, checksumed);
handleKeyDown = (event) => {
const codeName = keycode(event);
if (event.ctrlKey) {
return event;
}
switch (codeName) {
case 'enter':
const index = this.state.focusedItem;
if (!index) {
return this.handleCustomInput();
}
return this.handleDOMAction(`account_${index}`, 'click');
case 'left':
case 'right':
case 'up':
case 'down':
return this.handleNavigation(codeName, event);
default:
const code = codes[codeName];
// @see https://github.com/timoxley/keycode/blob/master/index.js
// lower case chars
if (code >= (97 - 32) && code <= (122 - 32)) {
return this.handleDOMAction(this.inputRef, 'focus');
}
// numbers
if (code >= 48 && code <= 57) {
return this.handleDOMAction(this.inputRef, 'focus');
}
return event;
}
}
handleDOMAction = (ref, method) => {
const refItem = typeof ref === 'string' ? this.refs[ref] : ref;
const element = ReactDOM.findDOMNode(refItem);
if (!element || typeof element[method] !== 'function') {
console.warn('could not find', ref, 'or method', method);
return;
}
return element[method]();
}
focusItem = (index) => {
this.setState({ focusedItem: index });
return this.handleDOMAction(`account_${index}`, 'focus');
}
handleNavigation = (direction, event) => {
const { focusedItem, focusedCat, values } = this.state;
// Don't do anything if no values
if (values.length === 0) {
return event;
}
// Focus on the first element if none selected yet if going down
if (!focusedItem) {
if (direction !== 'down') {
return event;
}
event.preventDefault();
const nextValues = values[focusedCat || 0];
const nextFocus = nextValues ? nextValues.values[0] : null;
return this.focusItem(nextFocus && nextFocus.index || 1);
}
event.preventDefault();
// Find the previous focused category
const prevCategoryIndex = values.findIndex((category) => {
return category.values.find((value) => value.index === focusedItem);
});
const prevFocusIndex = values[prevCategoryIndex].values.findIndex((a) => a.index === focusedItem);
let nextCategory = prevCategoryIndex;
let nextFocusIndex;
// If down: increase index if possible
if (direction === 'down') {
const prevN = values[prevCategoryIndex].values.length;
nextFocusIndex = Math.min(prevFocusIndex + 1, prevN - 1);
}
// If up: decrease index if possible
if (direction === 'up') {
// Focus on search if at the top
if (prevFocusIndex === 0) {
return this.handleDOMAction(this.inputRef, 'focus');
}
nextFocusIndex = prevFocusIndex - 1;
}
// If right: next category
if (direction === 'right') {
nextCategory = Math.min(prevCategoryIndex + 1, values.length - 1);
}
// If right: previous category
if (direction === 'left') {
nextCategory = Math.max(prevCategoryIndex - 1, 0);
}
// If left or right: try to keep the horizontal index
if (direction === 'left' || direction === 'right') {
this.setState({ focusedCat: nextCategory });
nextFocusIndex = Math.min(prevFocusIndex, values[nextCategory].values.length - 1);
}
const nextFocus = values[nextCategory].values[nextFocusIndex].index;
return this.focusItem(nextFocus);
}
handleClick = (address) => {
// Don't do anything if it's only text-selection
if (window.getSelection && window.getSelection().type === 'Range') {
return;
}
this.props.onChange(null, address);
this.handleClose();
}
handleMainBlur = () => {
if (window.document.hasFocus() && !this.state.expanded) {
this.closing = false;
this.setState({ focused: false });
}
}
handleMainFocus = () => {
if (this.state.focused) {
return;
}
this.setState({ focused: true }, () => {
if (this.closing) {
this.closing = false;
return;
}
this.handleFocus();
});
}
handleFocus = () => {
this.setState({ expanded: true, focusedItem: null, focusedCat: null }, () => {
window.setTimeout(() => {
this.handleDOMAction(this.inputRef, 'focus');
});
});
}
handleClose = () => {
this.closing = true;
if (this.refs.inputAddress) {
this.handleDOMAction('inputAddress', 'focus');
}
this.setState({ expanded: false });
}
/**
* Filter the given values based on the given
* filter
*/
filterValues = (values = [], _filter = '') => {
const filter = _filter.toLowerCase();
return values
// Remove empty accounts
.filter((a) => a)
.filter((account) => {
const address = account.address.toLowerCase();
const inAddress = address.includes(filter);
if (!account.name || inAddress) {
return inAddress;
}
const name = account.name.toLowerCase();
const inName = name.includes(filter);
const { meta = {} } = account;
if (!meta.tags || inName) {
return inName;
}
const tags = (meta.tags || []).join('');
return tags.includes(filter);
})
.sort((accA, accB) => {
const nameA = accA.name || accA.address;
const nameB = accB.name || accB.address;
return nameA.localeCompare(nameB);
});
}
handleInputBlur = () => {
this.setState({ inputFocused: false });
}
handleInputFocus = () => {
this.setState({ focusedItem: null, inputFocused: true });
}
handleChange = (event = { target: {} }) => {
const { value = '' } = event.target;
let index = 0;
const values = this.values
.map((category) => {
const filteredValues = this
.filterValues(category.values, value)
.map((value) => {
index++;
return { ...value, index: parseInt(index) };
});
return {
label: category.label,
values: filteredValues
};
});
this.setState({
values,
focusedItem: null,
inputValue: value
});
}
}
function mapStateToProps (state) {
const { accountsInfo } = state.personal;
const { balances } = state.balances;
return {
accountsInfo,
balances
};
}
export default connect(
mapStateToProps
)(AddressSelect);