diff --git a/js/package.json b/js/package.json index e18e61b06..a18e4f310 100644 --- a/js/package.json +++ b/js/package.json @@ -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", diff --git a/js/src/modals/ExecuteContract/DetailsStep/detailsStep.spec.js b/js/src/modals/ExecuteContract/DetailsStep/detailsStep.spec.js index fb80bdce6..5c59940eb 100644 --- a/js/src/modals/ExecuteContract/DetailsStep/detailsStep.spec.js +++ b/js/src/modals/ExecuteContract/DetailsStep/detailsStep.spec.js @@ -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( - + { +describe('modals/ExecuteContract', () => { it('renders', () => { expect(render({ accounts: {} })).to.be.ok; }); diff --git a/js/src/modals/ExecuteContract/executeContract.test.js b/js/src/modals/ExecuteContract/executeContract.test.js index 212aba2c8..cf2e1d294 100644 --- a/js/src/modals/ExecuteContract/executeContract.test.js +++ b/js/src/modals/ExecuteContract/executeContract.test.js @@ -53,6 +53,12 @@ const STORE = { }, nodeStatus: { gasLimit: new BigNumber(123) + }, + personal: { + accountsInfo: {} + }, + settings: { + backgroundSeed: '' } }; } diff --git a/js/src/modals/Transfer/Extras/extras.js b/js/src/modals/Transfer/Extras/extras.js index d4e66dda7..630739ac1 100644 --- a/js/src/modals/Transfer/Extras/extras.js +++ b/js/src/modals/Transfer/Extras/extras.js @@ -44,12 +44,12 @@ export default class Extras extends Component { error={ minBlockError } hint={ } label={ } value={ minBlock } diff --git a/js/src/modals/UpgradeParity/store.js b/js/src/modals/UpgradeParity/store.js index d7a4efd3e..587872407 100644 --- a/js/src/modals/UpgradeParity/store.js +++ b/js/src/modals/UpgradeParity/store.js @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with Parity. If not, see . -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) => { diff --git a/js/src/redux/providers/balancesActions.js b/js/src/redux/providers/balancesActions.js index 36e3fbb7c..838f850d5 100644 --- a/js/src/redux/providers/balancesActions.js +++ b/js/src/redux/providers/balancesActions.js @@ -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; diff --git a/js/src/redux/providers/certifications/middleware.js b/js/src/redux/providers/certifications/middleware.js index 010611274..840bd0b60 100644 --- a/js/src/redux/providers/certifications/middleware.js +++ b/js/src/redux/providers/certifications/middleware.js @@ -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); }); }); diff --git a/js/src/ui/AccountCard/accountCard.css b/js/src/ui/AccountCard/accountCard.css new file mode 100644 index 000000000..67b1704bc --- /dev/null +++ b/js/src/ui/AccountCard/accountCard.css @@ -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 . +*/ + +.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); + } +} diff --git a/js/src/ui/AccountCard/accountCard.js b/js/src/ui/AccountCard/accountCard.js new file mode 100644 index 000000000..3c0062b22 --- /dev/null +++ b/js/src/ui/AccountCard/accountCard.js @@ -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 . + +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 ( +
+ +
+
+ { displayName } +
+ + { this.renderTags(tags, address) } + { this.renderAddress(displayName, address) } + { this.renderBalance(address) } +
+
+ ); + } + + renderAddress (name, address) { + if (name === address) { + return null; + } + + return ( +
+ + { address } + +
+ ); + } + + renderTags (tags = [], address) { + if (tags.length === 0) { + return null; + } + + return ( + + ); + } + + 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 ( +
+ { value } + ETH +
+ ); + } + + 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); + } +} diff --git a/js/src/ui/AccountCard/index.js b/js/src/ui/AccountCard/index.js new file mode 100644 index 000000000..4adfc46d2 --- /dev/null +++ b/js/src/ui/AccountCard/index.js @@ -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 . + +export default from './accountCard'; diff --git a/js/src/ui/Form/AddressSelect/addressSelect.css b/js/src/ui/Form/AddressSelect/addressSelect.css index 60a6d42cf..25fb67f1c 100644 --- a/js/src/ui/Form/AddressSelect/addressSelect.css +++ b/js/src/ui/Form/AddressSelect/addressSelect.css @@ -14,53 +14,110 @@ /* You should have received a copy of the GNU General Public License /* along with Parity. If not, see . */ -.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; + } } diff --git a/js/src/ui/Form/AddressSelect/addressSelect.js b/js/src/ui/Form/AddressSelect/addressSelect.js index 550783669..86f4b191b 100644 --- a/js/src/ui/Form/AddressSelect/addressSelect.js +++ b/js/src/ui/Form/AddressSelect/addressSelect.js @@ -14,277 +14,599 @@ // You should have received a copy of the GNU General Public License // along with Parity. If not, see . -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 ( -
- - } } /> - } - label={ label } - onBlur={ this.onBlur } - onChange={ this.onChange } - onUpdateInput={ allowInput && this.onUpdateInput } - renderItem={ this.renderItem } - value={ searchText } /> - { icon } -
- ); - } - - 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 ( - + onBlur={ this.handleMainBlur } + onClick={ this.handleFocus } + onFocus={ this.handleMainFocus } + onKeyDown={ this.handleInputAddresKeydown } + ref='inputAddress' + tabIndex={ 0 } + > + { input } + { content } + ); } - 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 = ( + + ); - 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 ( +
+ { input } +
+ ); } - 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 ( + + + + +
+ +
+ + { this.renderCurrentInput() } + { this.renderAccounts() } +
+ ); + } + + renderCurrentInput () { + const { inputValue } = this.state; + + if (!this.props.allowInput || !inputValue) { + return null; + } + + const { address, addressError } = validateAddress(inputValue); + + if (addressError) { + return null; + } + + return ( +
+ { this.renderAccountCard({ address }) } +
+ ); + } + + renderAccounts () { + const { values } = this.state; + + if (values.length === 0) { + return ( +
+
+ +
+
+ ); + } + + const categories = values.map((category) => { + return this.renderCategory(category.label, category.values); + }); + + return ( +
+ { categories } +
+ ); + } + + renderCategory (name, values = []) { + let content; + + if (values.length === 0) { + content = ( +

+ +

+ ); + } else { + const cards = values + .map((account) => this.renderAccountCard(account)); + + content = ( +
+
{ cards }
+
+ ); + } + + return ( +
+
{ name }
+ { content } +
+ ); + } + + 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 ( -
- { value.toFormat(3) } { 'ETH' } -
+ ); } - renderMenuItem (address) { - const balance = this.props.balances - ? this.renderBalance(address) - : null; - - const item = ( -
- - - { balance } -
- ); - - return ( - - { item } - - ); + 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); diff --git a/js/src/ui/Form/Input/input.js b/js/src/ui/Form/Input/input.js index 92d313b5a..616bf23b1 100644 --- a/js/src/ui/Form/Input/input.js +++ b/js/src/ui/Form/Input/input.js @@ -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 (
{ 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 } diff --git a/js/src/ui/Form/InputAddress/inputAddress.js b/js/src/ui/Form/InputAddress/inputAddress.js index 917a273d4..c685081e0 100644 --- a/js/src/ui/Form/InputAddress/inputAddress.js +++ b/js/src/ui/Form/InputAddress/inputAddress.js @@ -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 diff --git a/js/src/ui/Icons/index.js b/js/src/ui/Icons/index.js index d27af1554..64b75109b 100644 --- a/js/src/ui/Icons/index.js +++ b/js/src/ui/Icons/index.js @@ -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, diff --git a/js/src/ui/ParityBackground/parityBackground.js b/js/src/ui/ParityBackground/parityBackground.js index 4bbfd4772..6e554b846 100644 --- a/js/src/ui/ParityBackground/parityBackground.js +++ b/js/src/ui/ParityBackground/parityBackground.js @@ -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 (
+ onTouchTap={ onClick } + > { children }
); } } -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( diff --git a/js/src/ui/Portal/index.js b/js/src/ui/Portal/index.js new file mode 100644 index 000000000..36bca9caa --- /dev/null +++ b/js/src/ui/Portal/index.js @@ -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 . + +export default from './portal'; diff --git a/js/src/ui/Portal/portal.css b/js/src/ui/Portal/portal.css new file mode 100644 index 000000000..905f2cbd3 --- /dev/null +++ b/js/src/ui/Portal/portal.css @@ -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 . +*/ + +.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; + } +} diff --git a/js/src/ui/Portal/portal.js b/js/src/ui/Portal/portal.js new file mode 100644 index 000000000..f81127540 --- /dev/null +++ b/js/src/ui/Portal/portal.js @@ -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 . + +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 ( + +
+ + + { this.renderCloseIcon() } + { children } +
+
+ ); + } + + renderCloseIcon () { + const { expanded } = this.state; + + if (!expanded) { + return null; + } + + return ( +
+ +
+ ); + } + + 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](); + } +} diff --git a/js/src/ui/Tags/tags.css b/js/src/ui/Tags/tags.css index ccd1d097b..b5a9881a4 100644 --- a/js/src/ui/Tags/tags.css +++ b/js/src/ui/Tags/tags.css @@ -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 { diff --git a/js/src/ui/Tags/tags.js b/js/src/ui/Tags/tags.js index a0db6fc6a..63b31fea1 100644 --- a/js/src/ui/Tags/tags.js +++ b/js/src/ui/Tags/tags.js @@ -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 {
+ onClick={ onClick } + ref={ setRef } + > { tag }
); diff --git a/js/src/views/Application/Container/container.js b/js/src/views/Application/Container/container.js index 421483e54..80e3571c1 100644 --- a/js/src/views/Application/Container/container.js +++ b/js/src/views/Application/Container/container.js @@ -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 ( - + diff --git a/js/src/views/ParityBar/parityBar.js b/js/src/views/ParityBar/parityBar.js index 2f7c52957..42c52f47d 100644 --- a/js/src/views/ParityBar/parityBar.js +++ b/js/src/views/ParityBar/parityBar.js @@ -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 (
- +