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

@ -168,6 +168,7 @@
"react-dom": "15.4.1",
"react-dropzone": "3.7.3",
"react-intl": "2.1.5",
"react-portal": "3.0.0",
"react-redux": "4.4.6",
"react-router": "3.0.0",
"react-router-redux": "4.0.7",

View File

@ -22,7 +22,7 @@ import { ContextProvider, muiTheme } from '~/ui';
import DetailsStep from './';
import { CONTRACT } from '../executeContract.test.js';
import { STORE, CONTRACT } from '../executeContract.test.js';
let component;
let onAmountChange;
@ -41,7 +41,7 @@ function render (props) {
onValueChange = sinon.stub();
component = mount(
<ContextProvider api={ {} } muiTheme={ muiTheme } store={ {} }>
<ContextProvider api={ {} } muiTheme={ muiTheme } store={ STORE }>
<DetailsStep
{ ...props }
contract={ CONTRACT }

View File

@ -42,7 +42,7 @@ function render (props) {
return component;
}
describe('modals/ExecuteContract/DetailsStep', () => {
describe('modals/ExecuteContract', () => {
it('renders', () => {
expect(render({ accounts: {} })).to.be.ok;
});

View File

@ -53,6 +53,12 @@ const STORE = {
},
nodeStatus: {
gasLimit: new BigNumber(123)
},
personal: {
accountsInfo: {}
},
settings: {
backgroundSeed: ''
}
};
}

View File

@ -44,12 +44,12 @@ export default class Extras extends Component {
error={ minBlockError }
hint={
<FormattedMessage
id='executeContract.advanced.minBlock.hint'
id='transferModal.minBlock.hint'
defaultMessage='Only post the transaction after this block' />
}
label={
<FormattedMessage
id='executeContract.advanced.minBlock.label'
id='transferModal.minBlock.label'
defaultMessage='BlockNumber to send from' />
}
value={ minBlock }

View File

@ -14,7 +14,7 @@
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
import { action, computed, observable, transaction } from 'mobx';
import { action, computed, observable, transaction, toJS } from 'mobx';
import store from 'store';
const LS_UPDATE = '_parity::update';
@ -129,7 +129,10 @@ export default class Store {
this._api.parity.versionInfo()
])
.then(([available, consensusCapability, version]) => {
console.log('[checkUpgrade]', 'available:', available, 'version:', version, 'consensusCapability:', consensusCapability);
if (!this.version || version.hash !== this.version.hash) {
console.log('[checkUpgrade]', 'available:', available, 'version:', toJS(version.version), 'consensusCapability:', consensusCapability);
}
this.setVersions(available, version, consensusCapability);
})
.catch((error) => {

View File

@ -48,7 +48,7 @@ function setBalances (_balances) {
const balance = Object.assign({}, balances[address]);
const { tokens, txCount = balance.txCount } = nextBalances[address];
const nextTokens = [].concat(balance.tokens);
const nextTokens = balance.tokens.slice();
tokens.forEach((t) => {
const { token, value } = t;

View File

@ -127,6 +127,10 @@ export default class CertificationsMiddleware {
}
})
.catch((err) => {
if (/does not exist/.test(err.toString())) {
return console.warn(err.toString());
}
console.warn(`Could not fetch certifier ${id}:`, err);
});
});

View File

@ -0,0 +1,105 @@
/* Copyright 2015, 2016 Parity Technologies (UK) Ltd.
/* This file is part of Parity.
/*
/* Parity is free software: you can redistribute it and/or modify
/* it under the terms of the GNU General Public License as published by
/* the Free Software Foundation, either version 3 of the License, or
/* (at your option) any later version.
/*
/* Parity is distributed in the hope that it will be useful,
/* but WITHOUT ANY WARRANTY; without even the implied warranty of
/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
/* GNU General Public License for more details.
/*
/* 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: 1em;
margin: 0.5em 0;
display: flex;
flex-direction: row;
align-items: center;
background-color: rgba(0, 0, 0, 0.8);
transition: transform ease-out 0.1s;
transform: scale(1);
&.copied {
animation-duration: 0.25s;
animation-name: copied;
}
&:focus {
transform: scale(0.99);
background-color: rgba(0, 0, 0, 0.6);
}
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
&:hover {
cursor: pointer;
background-color: rgba(0, 0, 0, 0.4);
}
}
.accountInfo {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
min-width: 0;
> * {
padding: 0.25em 0;
}
.addressContainer {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.9em;
.address {
&:hover {
cursor: text;
}
}
}
.accountName {
font-weight: 700 !important;
}
}
.balance {
.tag {
margin-left: 0.5em;
font-size: 0.85em;
}
}
@keyframes copied {
from {
transform: scale(0.99);
}
50% {
transform: scale(0.97);
}
to {
transform: scale(0.99);
}
}

View File

@ -0,0 +1,190 @@
// Copyright 2015, 2016 Parity Technologies (UK) Ltd.
// This file is part of Parity.
// Parity is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Parity is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
import React, { Component, PropTypes } from 'react';
import ReactDOM from 'react-dom';
import keycode from 'keycode';
import IdentityIcon from '~/ui/IdentityIcon';
import Tags from '~/ui/Tags';
import { fromWei } from '~/api/util/wei';
import styles from './accountCard.css';
export default class AccountCard extends Component {
static propTypes = {
account: PropTypes.object.isRequired,
onClick: PropTypes.func.isRequired,
onFocus: PropTypes.func.isRequired,
balance: PropTypes.object
};
state = {
copied: false
};
render () {
const { account } = this.props;
const { copied } = this.state;
const { address, name, meta = {} } = account;
const displayName = (name && name.toUpperCase()) || address;
const { tags = [] } = meta;
const classes = [ styles.account ];
if (copied) {
classes.push(styles.copied);
}
return (
<div
key={ address }
tabIndex={ 0 }
className={ classes.join(' ') }
onClick={ this.onClick }
onFocus={ this.onFocus }
onKeyDown={ this.handleKeyDown }
>
<IdentityIcon address={ address } />
<div className={ styles.accountInfo }>
<div className={ styles.accountName }>
<span>{ displayName }</span>
</div>
{ this.renderTags(tags, address) }
{ this.renderAddress(displayName, address) }
{ this.renderBalance(address) }
</div>
</div>
);
}
renderAddress (name, address) {
if (name === address) {
return null;
}
return (
<div className={ styles.addressContainer }>
<span
className={ styles.address }
onClick={ this.preventEvent }
ref={ `address` }
title={ address }
>
{ address }
</span>
</div>
);
}
renderTags (tags = [], address) {
if (tags.length === 0) {
return null;
}
return (
<Tags tags={ tags } />
);
}
renderBalance (address) {
const { balance = {} } = this.props;
if (!balance.tokens) {
return null;
}
const ethToken = balance.tokens
.find((tok) => tok.token && (tok.token.tag || '').toLowerCase() === 'eth');
if (!ethToken) {
return null;
}
const value = fromWei(ethToken.value).toFormat(3);
return (
<div className={ styles.balance }>
<span>{ value }</span>
<span className={ styles.tag }>ETH</span>
</div>
);
}
handleKeyDown = (event) => {
const codeName = keycode(event);
if (event.ctrlKey) {
// Copy the selected address if nothing selected and there is
// a focused item
const isSelection = !window.getSelection || window.getSelection().type === 'Range';
if (codeName === 'c' && !isSelection) {
const element = ReactDOM.findDOMNode(this.refs.address);
// Copy the address from the right element
// @see https://developers.google.com/web/updates/2015/04/cut-and-copy-commands
try {
const range = document.createRange();
range.selectNode(element);
window.getSelection().addRange(range);
document.execCommand('copy');
try {
window.getSelection().removeRange(range);
} catch (e) {
window.getSelection().removeAllRanges();
}
this.setState({ copied: true }, () => {
window.setTimeout(() => {
this.setState({ copied: false });
}, 250);
});
} catch (e) {
console.warn('could not copy');
}
}
return event;
}
}
onClick = () => {
const { account, onClick } = this.props;
onClick(account.address);
}
onFocus = () => {
const { account, onFocus } = this.props;
onFocus(account.index);
}
preventEvent = (e) => {
e.preventDefault();
e.stopPropagation();
}
setTagRef = (tagRef) => {
this.tagRefs.push(tagRef);
}
}

View File

@ -0,0 +1,17 @@
// Copyright 2015, 2016 Parity Technologies (UK) Ltd.
// This file is part of Parity.
// Parity is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Parity is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
export default from './accountCard';

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);

View File

@ -39,6 +39,10 @@ const UNDERLINE_NORMAL = {
borderBottom: 'solid 2px'
};
const UNDERLINE_FOCUSED = {
transform: 'scaleX(1.0)'
};
const NAME_ID = ' ';
export default class Input extends Component {
@ -51,6 +55,7 @@ export default class Input extends Component {
className: PropTypes.string,
disabled: PropTypes.bool,
error: nodeOrStringProptype(),
focused: PropTypes.bool,
readOnly: PropTypes.bool,
floatCopy: PropTypes.bool,
hint: nodeOrStringProptype(),
@ -61,9 +66,12 @@ export default class Input extends Component {
multiLine: PropTypes.bool,
onBlur: PropTypes.func,
onChange: PropTypes.func,
onClick: PropTypes.func,
onFocus: PropTypes.func,
onKeyDown: PropTypes.func,
onSubmit: PropTypes.func,
rows: PropTypes.number,
tabIndex: PropTypes.number,
type: PropTypes.string,
submitOnBlur: PropTypes.bool,
style: PropTypes.object,
@ -92,11 +100,20 @@ export default class Input extends Component {
if ((newProps.value !== this.props.value) && (newProps.value !== this.state.value)) {
this.setValue(newProps.value);
}
if (newProps.focused && !this.props.focused) {
this.refs.input.setState({ isFocused: true });
}
if (!newProps.focused && this.props.focused) {
this.refs.input.setState({ isFocused: false });
}
}
render () {
const { value } = this.state;
const { children, className, disabled, error, hideUnderline, hint, label, max, min, multiLine, rows, style, type } = this.props;
const { children, className, hideUnderline, disabled, error, focused, label } = this.props;
const { hint, onClick, onFocus, multiLine, rows, type, min, max, style, tabIndex } = this.props;
const readOnly = this.props.readOnly || disabled;
@ -111,6 +128,11 @@ export default class Input extends Component {
textFieldStyle.height = 'initial';
}
const underlineStyle = readOnly ? UNDERLINE_READONLY : UNDERLINE_NORMAL;
const underlineFocusStyle = focused
? UNDERLINE_FOCUSED
: readOnly && typeof focused !== 'boolean' ? { display: 'none' } : null;
return (
<div className={ styles.container } style={ style }>
{ this.renderCopyButton() }
@ -130,15 +152,19 @@ export default class Input extends Component {
name={ NAME_ID }
onBlur={ this.onBlur }
onChange={ this.onChange }
onClick={ onClick }
onKeyDown={ this.onKeyDown }
onFocus={ onFocus }
onPaste={ this.onPaste }
readOnly={ readOnly }
ref='input'
rows={ rows }
style={ textFieldStyle }
tabIndex={ tabIndex }
type={ type || 'text' }
underlineDisabledStyle={ UNDERLINE_DISABLED }
underlineStyle={ readOnly ? UNDERLINE_READONLY : UNDERLINE_NORMAL }
underlineFocusStyle={ readOnly ? { display: 'none' } : null }
underlineStyle={ underlineStyle }
underlineFocusStyle={ underlineFocusStyle }
underlineShow={ !hideUnderline }
value={ value }>
{ children }

View File

@ -34,12 +34,17 @@ class InputAddress extends Component {
className: PropTypes.string,
disabled: PropTypes.bool,
error: PropTypes.string,
focused: PropTypes.bool,
hideUnderline: PropTypes.bool,
hint: nodeOrStringProptype(),
label: nodeOrStringProptype(),
onChange: PropTypes.func,
onClick: PropTypes.func,
onFocus: PropTypes.func,
onSubmit: PropTypes.func,
readOnly: PropTypes.bool,
small: PropTypes.bool,
tabIndex: PropTypes.number,
text: PropTypes.bool,
tokens: PropTypes.object,
value: PropTypes.string
@ -52,10 +57,11 @@ class InputAddress extends Component {
};
render () {
const { className, disabled, error, hint, label, text, value } = this.props;
const { accountsInfo, allowCopy, hideUnderline, onSubmit, small, tokens } = this.props;
const { accountsInfo, allowCopy, className, disabled, error, focused, hint } = this.props;
const { hideUnderline, label, onClick, onFocus, onSubmit, readOnly, small } = this.props;
const { tabIndex, text, tokens, value } = this.props;
const account = accountsInfo[value] || tokens[value];
const account = value && (accountsInfo[value] || tokens[value]);
const icon = this.renderIcon();
@ -63,7 +69,7 @@ class InputAddress extends Component {
classes.push(!icon ? styles.inputEmpty : styles.input);
const containerClasses = [ styles.container ];
const nullName = new BigNumber(value).eq(0) ? 'null' : null;
const nullName = value && new BigNumber(value).eq(0) ? 'null' : null;
if (small) {
containerClasses.push(styles.small);
@ -76,11 +82,16 @@ class InputAddress extends Component {
className={ classes.join(' ') }
disabled={ disabled }
error={ error }
focused={ focused }
hideUnderline={ hideUnderline }
hint={ hint }
label={ label }
onChange={ this.handleInputChange }
onClick={ onClick }
onFocus={ onFocus }
onSubmit={ onSubmit }
readOnly={ readOnly }
tabIndex={ tabIndex }
value={
text && account
? account.name

View File

@ -16,6 +16,7 @@
import AddIcon from 'material-ui/svg-icons/content/add';
import CancelIcon from 'material-ui/svg-icons/content/clear';
import CloseIcon from 'material-ui/svg-icons/navigation/close';
import ContractIcon from 'material-ui/svg-icons/action/code';
import DoneIcon from 'material-ui/svg-icons/action/done-all';
import PrevIcon from 'material-ui/svg-icons/navigation/arrow-back';
@ -25,6 +26,7 @@ import SnoozeIcon from 'material-ui/svg-icons/av/snooze';
export {
AddIcon,
CancelIcon,
CloseIcon,
ContractIcon,
DoneIcon,
PrevIcon,

View File

@ -18,48 +18,70 @@ import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
class ParityBackground extends Component {
static contextTypes = {
muiTheme: PropTypes.object.isRequired
};
static propTypes = {
style: PropTypes.object.isRequired,
backgroundSeed: PropTypes.string,
children: PropTypes.node,
className: PropTypes.string,
onClick: PropTypes.func
};
state = {
style: {}
};
_seed = null;
componentWillMount () {
this.setStyle();
}
componentWillReceiveProps (nextProps) {
this.setStyle(nextProps);
}
shouldComponentUpdate (_, nextState) {
return nextState.style !== this.state.style;
}
setStyle (props = this.props) {
const { seed, gradient, backgroundSeed } = props;
const _seed = seed || backgroundSeed;
// Don't update if it's the same seed...
if (this._seed === _seed) {
return;
}
const { muiTheme } = this.context;
const style = muiTheme.parity.getBackgroundStyle(gradient, _seed);
this.setState({ style });
}
render () {
const { children, className, style, onClick } = this.props;
const { children, className, onClick } = this.props;
const { style } = this.state;
return (
<div
className={ className }
style={ style }
onTouchTap={ onClick }>
onTouchTap={ onClick }
>
{ children }
</div>
);
}
}
function mapStateToProps (_, initProps) {
const { gradient, seed, muiTheme } = initProps;
let _seed = seed;
let _props = { style: muiTheme.parity.getBackgroundStyle(gradient, seed) };
return (state, props) => {
const { backgroundSeed } = state.settings;
const { seed } = props;
const newSeed = seed || backgroundSeed;
if (newSeed === _seed) {
return _props;
}
_seed = newSeed;
_props = { style: muiTheme.parity.getBackgroundStyle(gradient, newSeed) };
return _props;
};
function mapStateToProps (state) {
const { backgroundSeed } = state.settings;
return { backgroundSeed };
}
export default connect(

17
js/src/ui/Portal/index.js Normal file
View File

@ -0,0 +1,17 @@
// Copyright 2015, 2016 Parity Technologies (UK) Ltd.
// This file is part of Parity.
// Parity is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Parity is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
export default from './portal';

View File

@ -0,0 +1,74 @@
/* Copyright 2015, 2016 Parity Technologies (UK) Ltd.
/* This file is part of Parity.
/*
/* Parity is free software: you can redistribute it and/or modify
/* it under the terms of the GNU General Public License as published by
/* the Free Software Foundation, either version 3 of the License, or
/* (at your option) any later version.
/*
/* Parity is distributed in the hope that it will be useful,
/* but WITHOUT ANY WARRANTY; without even the implied warranty of
/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
/* GNU General Public License for more details.
/*
/* You should have received a copy of the GNU General Public License
/* along with Parity. If not, see <http://www.gnu.org/licenses/>.
*/
.parityBackground {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
opacity: 0.25;
z-index: -1;
}
.overlay {
display: flex;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
transform-origin: 100% 0;
transition-property: opacity, z-index;
transition-duration: 0.25s;
transition-timing-function: ease-out;
background-color: rgba(0, 0, 0, 1);
opacity: 0;
z-index: -10;
* {
min-width: 0;
}
&.expanded {
opacity: 1;
z-index: 9999;
}
}
.closeIcon {
position: absolute;
top: 0.5rem;
right: 1rem;
font-size: 4em;
transition-property: opacity;
transition-duration: 0.25s;
transition-timing-function: ease-out;
&, * {
height: 48px !important;
width: 48px !important;
}
&:hover {
cursor: pointer;
opacity: 0.5;
}
}

125
js/src/ui/Portal/portal.js Normal file
View File

@ -0,0 +1,125 @@
// Copyright 2015, 2016 Parity Technologies (UK) Ltd.
// This file is part of Parity.
// Parity is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Parity is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
import React, { Component, PropTypes } from 'react';
import ReactDOM from 'react-dom';
import Portal from 'react-portal';
import keycode from 'keycode';
import { CloseIcon } from '~/ui/Icons';
import ParityBackground from '~/ui/ParityBackground';
import styles from './portal.css';
export default class Protal extends Component {
static propTypes = {
onClose: PropTypes.func.isRequired,
open: PropTypes.bool.isRequired,
children: PropTypes.node,
className: PropTypes.string,
onKeyDown: PropTypes.func
};
state = {
expanded: false
}
componentWillReceiveProps (nextProps) {
if (this.props.open !== nextProps.open) {
const opening = nextProps.open;
const closing = !opening;
if (opening) {
return this.setState({ expanded: true });
}
if (closing) {
return this.setState({ expanded: false });
}
}
}
render () {
const { expanded } = this.state;
const { children, className } = this.props;
const classes = [ styles.overlay, className ];
if (expanded) {
classes.push(styles.expanded);
}
return (
<Portal isOpened onClose={ this.handleClose }>
<div
className={ classes.join(' ') }
onKeyDown={ this.handleKeyDown }
>
<ParityBackground className={ styles.parityBackground } />
{ this.renderCloseIcon() }
{ children }
</div>
</Portal>
);
}
renderCloseIcon () {
const { expanded } = this.state;
if (!expanded) {
return null;
}
return (
<div className={ styles.closeIcon } onClick={ this.handleClose }>
<CloseIcon />
</div>
);
}
handleClose = () => {
this.props.onClose();
}
handleKeyDown = (event) => {
const codeName = keycode(event);
switch (codeName) {
case 'esc':
event.preventDefault();
return this.handleClose();
default:
event.persist();
return this.props.onKeyDown(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]();
}
}

View File

@ -29,6 +29,8 @@
border-radius: 16px;
margin: 0.75em 0.5em 0 0;
padding: 0.25em 1em;
opacity: 1;
transition: opacity 0.2s ease-out;
}
.tagClickable:hover {

View File

@ -20,8 +20,9 @@ import styles from './tags.css';
export default class Tags extends Component {
static propTypes = {
tags: PropTypes.array,
handleAddSearchToken: PropTypes.func
handleAddSearchToken: PropTypes.func,
setRefs: PropTypes.func,
tags: PropTypes.array
}
render () {
@ -31,13 +32,17 @@ export default class Tags extends Component {
}
renderTags () {
const { handleAddSearchToken } = this.props;
const { handleAddSearchToken, setRefs } = this.props;
const tags = this.props.tags || [];
const tagClasses = handleAddSearchToken
? [ styles.tag, styles.tagClickable ]
: [ styles.tag ];
const setRef = setRefs
? (ref) => { setRefs(ref); }
: () => {};
return tags
.sort()
.map((tag, idx) => {
@ -49,7 +54,9 @@ export default class Tags extends Component {
<div
key={ idx }
className={ tagClasses.join(' ') }
onClick={ onClick }>
onClick={ onClick }
ref={ setRef }
>
{ tag }
</div>
);

View File

@ -22,9 +22,6 @@ import { Errors, ParityBackground, Tooltips } from '~/ui';
import styles from '../application.css';
export default class Container extends Component {
static contextTypes = {
muiTheme: PropTypes.object.isRequired
};
static propTypes = {
children: PropTypes.node.isRequired,
@ -34,13 +31,10 @@ export default class Container extends Component {
};
render () {
const { muiTheme } = this.context;
const { children, onCloseFirstRun, showFirstRun, upgradeStore } = this.props;
return (
<ParityBackground
className={ styles.container }
muiTheme={ muiTheme }>
<ParityBackground className={ styles.container }>
<FirstRun
onClose={ onCloseFirstRun }
visible={ showFirstRun } />

View File

@ -28,9 +28,6 @@ import imagesEthcoreBlock from '../../../assets/images/parity-logo-white-no-text
import styles from './parityBar.css';
class ParityBar extends Component {
static contextTypes = {
muiTheme: PropTypes.object.isRequired
};
static propTypes = {
pending: PropTypes.array,
@ -66,7 +63,6 @@ class ParityBar extends Component {
renderBar () {
const { dapp } = this.props;
const { muiTheme } = this.context;
if (!dapp) {
return null;
@ -80,7 +76,7 @@ class ParityBar extends Component {
return (
<div className={ styles.bar }>
<ParityBackground className={ styles.corner } muiTheme={ muiTheme }>
<ParityBackground className={ styles.corner }>
<div className={ styles.cornercolor }>
<Link to='/apps'>
<Button
@ -100,11 +96,9 @@ class ParityBar extends Component {
}
renderExpanded () {
const { muiTheme } = this.context;
return (
<div className={ styles.overlay }>
<ParityBackground className={ styles.expanded } muiTheme={ muiTheme }>
<ParityBackground className={ styles.expanded }>
<div className={ styles.header }>
<div className={ styles.title }>
<ContainerTitle title='Parity Signer: Pending' />

View File

@ -95,7 +95,6 @@ class Background extends Component {
renderBackgrounds () {
const { settings } = this.props;
const { seeds } = this.state;
const { muiTheme } = this.context;
return seeds.map((seed, index) => {
return (
@ -105,7 +104,6 @@ class Background extends Component {
className={ settings.backgroundSeed === seed ? styles.seedactive : styles.seed }
seed={ seed }
onClick={ this.onSelect(seed) }
muiTheme={ muiTheme }
/>
</div>
</div>