Refactor Dropdowns, Rainbow Node Selector (#244)

* Convert all dropdowns to use a single dropdown shell component. Restyle header ones to look like v3.

* Right align some.

* Color dropdown component, which node selector uses.

* Prettier fixes.
This commit is contained in:
William O'Beirne 2017-10-02 15:36:59 -07:00 committed by Daniel Ternyak
parent ad83b5a181
commit 1a09c6a7a6
13 changed files with 448 additions and 277 deletions

View File

@ -1,90 +1,86 @@
import { gasPriceDefaults } from 'config/data';
import throttle from 'lodash/throttle';
import React, { Component } from 'react';
import DropdownShell from 'components/ui/DropdownShell';
import './GasPriceDropdown.scss';
interface Props {
value: number;
onChange(gasPrice: number): void;
}
interface State {
expanded: boolean;
}
export default class GasPriceDropdown extends Component<Props, State> {
public state = { expanded: false };
export default class GasPriceDropdown extends Component<Props, {}> {
constructor(props: Props) {
super(props);
this.updateGasPrice = throttle(this.updateGasPrice, 50);
}
public render() {
const { expanded } = this.state;
const { value } = this.props;
return (
<span className={`dropdown ${expanded ? 'open' : ''}`}>
<a
aria-haspopup="true"
aria-label="adjust gas price"
className="dropdown-toggle"
onClick={this.toggleExpanded}
>
<span>Gas Price</span>: {this.props.value} Gwei
<i className="caret" />
</a>
{expanded &&
<ul className="dropdown-menu GasPrice-dropdown-menu">
<div className="GasPrice-header">
<span>Gas Price</span>: {this.props.value} Gwei
<input
type="range"
value={this.props.value}
min={gasPriceDefaults.gasPriceMinGwei}
max={gasPriceDefaults.gasPriceMaxGwei}
onChange={this.handleGasPriceChange}
/>
<p className="small col-xs-4 text-left GasPrice-padding-reset">
Not So Fast
</p>
<p className="small col-xs-4 text-center GasPrice-padding-reset">
Fast
</p>
<p className="small col-xs-4 text-right GasPrice-padding-reset">
Fast AF
</p>
<p className="small GasPrice-description">
Gas Price is the amount you pay per unit of gas.{' '}
<code>TX fee = gas price * gas limit</code> & is paid to miners
for including your TX in a block. Higher the gas price = faster
transaction, but more expensive. Default is <code>21 GWEI</code>.
</p>
<p>
{/* TODO: maybe not hardcode a link? :) */}
<a
href="https://myetherwallet.groovehq.com/knowledge_base/topics/what-is-gas"
target="_blank"
>
Read more
</a>
</p>
</div>
</ul>}
</span>
<DropdownShell
color="white"
size="smr"
ariaLabel={`adjust gas price. current price is ${value} gwei`}
renderLabel={this.renderLabel}
renderOptions={this.renderOptions}
/>
);
}
public toggleExpanded = () => {
this.setState(state => {
return {
expanded: !state.expanded
};
});
private renderLabel = () => {
return `Gas Price: ${this.props.value} Gwei`;
};
public updateGasPrice = (value: string) => {
private renderOptions = () => {
const { value } = this.props;
return (
<div className="GasPrice-dropdown-menu dropdown-menu dropdown-menu-right">
<div className="GasPrice-header">
<span>Gas Price</span>: {value} Gwei
<input
type="range"
value={value}
min={gasPriceDefaults.gasPriceMinGwei}
max={gasPriceDefaults.gasPriceMaxGwei}
onChange={this.handleGasPriceChange}
/>
<p className="small col-xs-4 text-left GasPrice-padding-reset">
Not So Fast
</p>
<p className="small col-xs-4 text-center GasPrice-padding-reset">
Fast
</p>
<p className="small col-xs-4 text-right GasPrice-padding-reset">
Fast AF
</p>
<p className="small GasPrice-description">
Gas Price is the amount you pay per unit of gas.{' '}
<code>TX fee = gas price * gas limit</code> & is paid to miners for
including your TX in a block. Higher the gas price = faster
transaction, but more expensive. Default is <code>21 GWEI</code>.
</p>
<p>
{/* TODO: maybe not hardcode a link? :) */}
<a
href="https://myetherwallet.groovehq.com/knowledge_base/topics/what-is-gas"
target="_blank"
>
Read more
</a>
</p>
</div>
</div>
);
};
private updateGasPrice = (value: string) => {
this.props.onChange(parseInt(value, 10));
};
public handleGasPriceChange = (e: React.SyntheticEvent<HTMLInputElement>) => {
private handleGasPriceChange = (
e: React.SyntheticEvent<HTMLInputElement>
) => {
this.updateGasPrice((e.target as HTMLInputElement).value);
};
}

View File

@ -92,66 +92,37 @@ $small-size: 900px;
padding: 5px 0;
min-width: 220px;
}
&-tagline {
font-size: 18px;
font-weight: 200;
color: white;
flex: 1 auto;
text-align: right;
padding: 5px 0;
@include small-query {
text-align: center;
}
> * {
display: inline;
vertical-align: middle;
}
&-version {
max-width: 395px;
}
}
}
a {
&-right {
font-size: 18px;
font-weight: 200;
color: white;
cursor: pointer;
font-weight: 400;
transition: 250ms all ease;
&:hover,
&:active {
opacity: .8;
color: white;
text-decoration: none;
transition: 250ms all ease;
}
}
// TODO - Move to dropdown component?
.dropdown {
margin-left: 15px;
padding: 0;
flex: 1 auto;
text-align: right;
white-space: nowrap;
padding: 0 0 5px;
@include small-query {
text-align: center;
}
.dropdown-menu {
right: -10px;
left: auto;
min-width: auto;
left: auto;
> * {
display: inline-block;
vertical-align: middle;
margin-top: 5px;
}
& > li > a {
font-size: 15px;
padding: 5px 30px 5px 15px;
position: relative;
&-version {
max-width: 395px;
margin-right: 10px;
}
&.active {
text-decoration: none;
color: $brand-primary;
background-color: $gray-lightest;
}
&-dropdown {
margin-left: 6px;
&-add {
text-align: center;
padding-top: $space-sm !important;
padding-bottom: $space-sm !important;
}
}
}

View File

@ -1,6 +1,6 @@
import { TChangeGasPrice, TChangeLanguage, TChangeNode } from 'actions/config';
import logo from 'assets/images/logo-myetherwallet.svg';
import { Dropdown } from 'components/ui';
import { Dropdown, ColorDropdown } from 'components/ui';
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import {
@ -36,7 +36,18 @@ export default class Header extends Component<Props, {}> {
const LanguageDropDown = Dropdown as new () => Dropdown<
typeof selectedLanguage
>;
const NodeDropDown = Dropdown as new () => Dropdown<keyof typeof NODES>;
const nodeOptions = Object.keys(NODES).map(key => {
return {
value: key,
name: (
<span>
{NODES[key].network} <small>({NODES[key].service})</small>
</span>
),
color: NETWORKS[NODES[key].network].color
};
});
return (
<div className="Header">
{ANNOUNCEMENT_MESSAGE && (
@ -64,45 +75,51 @@ export default class Header extends Component<Props, {}> {
alt="MyEtherWallet"
/>
</Link>
<div className="Header-branding-title-tagline">
<span className="Header-branding-title-tagline-version">
v{VERSION}
</span>
<div className="Header-branding-right">
<span className="Header-branding-right-version">v{VERSION}</span>
<GasPriceDropdown
value={this.props.gasPriceGwei}
onChange={this.props.changeGasPrice}
/>
<div className="Header-branding-right-dropdown">
<GasPriceDropdown
value={this.props.gasPriceGwei}
onChange={this.props.changeGasPrice}
/>
</div>
<LanguageDropDown
ariaLabel={`change language. current language ${languages[
selectedLanguage
]}`}
options={Object.values(languages)}
value={languages[selectedLanguage]}
extra={[
<li key={'separator'} role="separator" className="divider" />,
<li key={'disclaimer'}>
<a data-toggle="modal" data-target="#disclaimerModal">
Disclaimer
</a>
</li>
]}
onChange={this.changeLanguage}
/>
<div className="Header-branding-right-dropdown">
<LanguageDropDown
ariaLabel={`change language. current language ${languages[
selectedLanguage
]}`}
options={Object.values(languages)}
value={languages[selectedLanguage]}
extra={
<li key="disclaimer">
<a data-toggle="modal" data-target="#disclaimerModal">
Disclaimer
</a>
</li>
}
onChange={this.changeLanguage}
size="smr"
color="white"
/>
</div>
<NodeDropDown
ariaLabel={`change node. current node ${selectedNode.network} node by ${selectedNode.service}`}
options={Object.keys(NODES)}
formatTitle={this.nodeNetworkAndService}
value={nodeSelection}
extra={
<li>
<a>Add Custom Node</a>
</li>
}
onChange={changeNode}
/>
<div className="Header-branding-right-dropdown">
<ColorDropdown
ariaLabel={`change node. current node ${selectedNode.network} node by ${selectedNode.service}`}
options={nodeOptions}
value={nodeSelection}
extra={
<li>
<a>Add Custom Node</a>
</li>
}
onChange={changeNode}
size="smr"
color="white"
/>
</div>
</div>
</section>
</section>
@ -121,10 +138,4 @@ export default class Header extends Component<Props, {}> {
this.props.changeLanguage(key);
}
};
private nodeNetworkAndService = (option: string) => [
NODES[option].network,
' ',
<small key="service">({NODES[option].service}) </small>
];
}

View File

@ -0,0 +1,106 @@
// @flow
import React, { Component } from 'react';
import classnames from 'classnames';
import DropdownShell from './DropdownShell';
interface Option<T> {
name: any;
value: T;
color?: string;
}
interface Props<T> {
value: T;
options: Option<T>[];
label?: string;
ariaLabel: string;
extra?: any;
size?: string;
color?: string;
menuAlign?: string;
onChange(value: T): void;
}
export default class ColorDropdown<T> extends Component<Props<T>, {}> {
private dropdownShell: DropdownShell | null;
public render() {
const { ariaLabel, color, size } = this.props;
return (
<DropdownShell
renderLabel={this.renderLabel}
renderOptions={this.renderOptions}
size={size}
color={color}
ariaLabel={ariaLabel}
ref={el => (this.dropdownShell = el)}
/>
);
}
private renderLabel = () => {
const label = this.props.label ? `${this.props.label}:` : '';
const activeOption = this.getActiveOption();
return (
<span>
{label} {activeOption ? activeOption.name : '-'}
</span>
);
};
private renderOptions = () => {
const { options, value, menuAlign, extra } = this.props;
const activeOption = this.getActiveOption();
const listItems = options.reduce((prev: any[], opt) => {
const prevOpt = prev.length ? prev[prev.length - 1] : null;
if (prevOpt && !prevOpt.divider && prevOpt.color !== opt.color) {
prev.push({ divider: true });
}
prev.push(opt);
return prev;
}, []);
const menuClass = classnames({
'dropdown-menu': true,
[`dropdown-menu-${menuAlign || ''}`]: !!menuAlign
});
return (
<ul className={menuClass}>
{listItems.map((option, i) => {
if (option.divider) {
return <li key={i} role="separator" className="divider" />;
} else {
return (
<li key={i} style={{ borderLeft: `2px solid ${option.color}` }}>
<a
className={option.value === value ? 'active' : ''}
onClick={this.onChange.bind(null, option.value)}
>
{option.name}
</a>
</li>
);
}
})}
{extra && <li key="separator" role="separator" className="divider" />}
{extra}
</ul>
);
};
private onChange = (value: any) => {
this.props.onChange(value);
if (this.dropdownShell) {
this.dropdownShell.close();
}
};
private getActiveOption() {
return this.props.options.find(opt => opt.value === this.props.value);
}
}

View File

@ -1,77 +1,87 @@
import React, { Component } from 'react';
import classnames from 'classnames';
import DropdownShell from './DropdownShell';
interface Props<T> {
value: T;
options: T[];
ariaLabel: string;
label?: string;
extra?: any;
size?: string;
color?: string;
menuAlign?: string;
formatTitle?(option: T): any;
onChange(value: T): void;
}
interface State {
expanded: boolean;
}
export default class DropdownComponent<T> extends Component<Props<T>, State> {
public state = {
expanded: false
};
export default class DropdownComponent<T> extends Component<Props<T>, {}> {
private dropdownShell: DropdownShell | null;
public render() {
const { options, value, ariaLabel, extra } = this.props;
const { expanded } = this.state;
const { ariaLabel, color, size } = this.props;
return (
<span className={`dropdown ${expanded ? 'open' : ''}`}>
<a
tabIndex={0}
aria-haspopup="true"
aria-expanded="false"
aria-label={ariaLabel}
className="dropdown-toggle"
onClick={this.toggleExpanded}
>
{this.props.formatTitle ? this.formatTitle(value) : value}
<i className="caret" />
</a>
{expanded && (
<ul className="dropdown-menu">
{options.map((option, i) => {
return (
<li key={i}>
<a
className={option === value ? 'active' : ''}
onClick={this.onChange.bind(null, option)}
>
{this.props.formatTitle ? this.formatTitle(option) : option}
</a>
</li>
);
})}
{extra}
</ul>
)}
</span>
<DropdownShell
renderLabel={this.renderLabel}
renderOptions={this.renderOptions}
size={size}
color={color}
ariaLabel={ariaLabel}
ref={el => (this.dropdownShell = el)}
/>
);
}
public formatTitle = (option: any) => {
private renderLabel = () => {
const { label, value } = this.props;
const labelStr = this.props.label ? `${this.props.label}:` : '';
return (
<span>
{labelStr} {this.formatTitle(value)}
</span>
);
};
private renderOptions = () => {
const { options, value, menuAlign, extra } = this.props;
const menuClass = classnames({
'dropdown-menu': true,
[`dropdown-menu-${menuAlign || ''}`]: !!menuAlign
});
return (
<ul className={menuClass}>
{options.map((option, i) => {
return (
<li key={i}>
<a
className={option === value ? 'active' : ''}
onClick={this.onChange.bind(null, option)}
>
{this.props.formatTitle ? this.formatTitle(option) : option}
</a>
</li>
);
})}
{extra && <li key={'separator'} role="separator" className="divider" />}
{extra}
</ul>
);
};
private formatTitle = (option: any) => {
if (this.props.formatTitle) {
return this.props.formatTitle(option);
} else {
return option;
}
};
public toggleExpanded = () => {
this.setState(state => {
return {
expanded: !state.expanded
};
});
};
public onChange = (value: any) => {
private onChange = (value: any) => {
this.props.onChange(value);
this.setState({ expanded: false });
if (this.dropdownShell) {
this.dropdownShell.close();
}
};
}

View File

@ -0,0 +1,95 @@
// @flow
import React, { Component } from 'react';
import classnames from 'classnames';
interface Props {
ariaLabel: string;
size?: string;
color?: string;
renderLabel(): any;
renderOptions(): any;
}
interface State {
expanded: boolean;
}
export default class DropdownComponent extends Component<Props, State> {
public static defaultProps = {
color: 'default',
size: 'sm'
};
public state: State = {
expanded: false
};
private dropdown: HTMLElement | null;
public componentDidMount() {
document.addEventListener('click', this.clickOffHandler);
}
public componentWillUnmount() {
document.removeEventListener('click', this.clickOffHandler);
}
public render() {
const { ariaLabel, color, size, renderOptions, renderLabel } = this.props;
const { expanded } = this.state;
const toggleClasses = classnames([
'dropdown-toggle',
'btn',
`btn-${color}`,
`btn-${size}`
]);
return (
<span
className={`dropdown ${expanded ? 'open' : ''}`}
ref={el => (this.dropdown = el)}
>
<a
tabIndex={0}
aria-haspopup="true"
aria-expanded={expanded}
aria-label={ariaLabel}
className={toggleClasses}
onClick={this.toggle}
>
{renderLabel()}
<i className="caret" />
</a>
{expanded && renderOptions()}
</span>
);
}
public toggle = () => {
this.setState({ expanded: !this.state.expanded });
};
public open = () => {
this.setState({ expanded: true });
};
public close = () => {
this.setState({ expanded: false });
};
private clickOffHandler = (ev: MouseEvent) => {
// Only calculate if dropdown is open & we have the ref
if (!this.state.expanded || !this.dropdown) {
return;
}
// If it's an element that's not inside of the dropdown, close it up
if (
this.dropdown !== ev.target &&
ev.target instanceof HTMLElement &&
!this.dropdown.contains(ev.target)
) {
this.setState({ expanded: false });
}
};
}

View File

@ -1,61 +0,0 @@
import React, { Component } from 'react';
interface Props {
value?: string;
options: string[];
onChange(value: string): void;
}
interface State {
expanded: boolean;
}
export default class SimpleDropDown extends Component<Props, State> {
public state = {
expanded: false
};
public toggleExpanded = () => {
this.setState(state => {
return { expanded: !state.expanded };
});
};
public onClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
const value = (event.target as HTMLAnchorElement).getAttribute('data-value') || '';
this.props.onChange(value);
this.setState({ expanded: false });
};
public render() {
const { options, value } = this.props;
const { expanded } = this.state;
return (
<div className={`dropdown ${expanded ? 'open' : ''}`}>
<a
className="btn btn-default dropdown-toggle"
onClick={this.toggleExpanded}
>
{value}
<i className="caret" />
</a>
{expanded &&
<ul className="dropdown-menu dropdown-menu-right">
{options.map((option, i) => {
return (
<li key={i}>
<a
className={option === value ? 'active' : ''}
onClick={this.onClick}
data-value={option}
>
{option}
</a>
</li>
);
})}
</ul>}
</div>
);
}
}

View File

@ -0,0 +1,26 @@
import React, { Component } from 'react';
import Dropdown from './Dropdown';
interface Props {
value?: string;
options: string[];
ariaLabel?: string;
onChange(value: string): void;
}
export default class SimpleDropdown extends Component<Props, void> {
public render() {
const { options, value, onChange, ariaLabel } = this.props;
const StringDropdown = Dropdown as new () => Dropdown<string>;
return (
<StringDropdown
options={options}
value={value}
onChange={onChange}
ariaLabel={ariaLabel || "dropdown"}
/>
);
}
}

View File

@ -1,4 +1,6 @@
export { default as ColorDropdown } from './ColorDropdown';
export { default as Dropdown } from './Dropdown';
export { default as DropdownShell } from './DropdownShell';
export { default as Identicon } from './Identicon';
export { default as Modal } from './Modal';
export { default as UnlockHeader } from './UnlockHeader';

View File

@ -1,5 +1,5 @@
import SimpleDropDown from 'components/ui/SimpleDropDown';
import React from 'react';
import SimpleDropdown from 'components/ui/SimpleDropdown';
interface UnitDropdownProps {
value: string;
@ -22,7 +22,7 @@ export default class UnitDropdown extends React.Component<
return (
<div className="input-group-btn">
<SimpleDropDown
<SimpleDropdown
value={value}
onChange={this.onChange}
options={options}

View File

@ -43,6 +43,16 @@
);
padding: .1rem .6rem .2rem;
}
// This is a "smaller" small, to accomodate overrides done in v3.
.btn-smr {
@include button-size(
.4rem,
1rem,
14px,
$line-height-base,
$border-radius
);
}
// Custom color
.btn-white {

View File

@ -7,6 +7,11 @@
}
}
// If it's a span, we probably want it inline-block, not inline
span.dropdown {
display: inline-block;
}
.dropdown-menu {
padding: 0;
border: none;

View File

@ -28,8 +28,8 @@
"react-dom": "16.0.0",
"react-markdown": "^2.5.0",
"react-redux": "^5.0.6",
"react-router-dom": "^4.2.2",
"react-router-redux": "^4.0.8",
"react-router-dom": "^4.2.2",
"react-router-redux": "^4.0.8",
"redux": "^3.6.0",
"redux-form": "^6.6.3",
"redux-logger": "^3.0.1",
@ -53,8 +53,8 @@
"@types/react": "^16.0.5",
"@types/react-dom": "^15.5.4",
"@types/react-redux": "^5.0.9",
"@types/react-router": "^4.0.15",
"@types/react-router-dom": "^4.0.8",
"@types/react-router": "^4.0.15",
"@types/react-router-dom": "^4.0.8",
"@types/react-router-redux": "^4.0.50",
"@types/redux-form": "^7.0.5",
"@types/redux-logger": "^3.0.3",