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,43 +1,46 @@
import { gasPriceDefaults } from 'config/data'; import { gasPriceDefaults } from 'config/data';
import throttle from 'lodash/throttle'; import throttle from 'lodash/throttle';
import React, { Component } from 'react'; import React, { Component } from 'react';
import DropdownShell from 'components/ui/DropdownShell';
import './GasPriceDropdown.scss'; import './GasPriceDropdown.scss';
interface Props { interface Props {
value: number; value: number;
onChange(gasPrice: number): void; 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) { constructor(props: Props) {
super(props); super(props);
this.updateGasPrice = throttle(this.updateGasPrice, 50); this.updateGasPrice = throttle(this.updateGasPrice, 50);
} }
public render() { public render() {
const { expanded } = this.state; const { value } = this.props;
return ( return (
<span className={`dropdown ${expanded ? 'open' : ''}`}> <DropdownShell
<a color="white"
aria-haspopup="true" size="smr"
aria-label="adjust gas price" ariaLabel={`adjust gas price. current price is ${value} gwei`}
className="dropdown-toggle" renderLabel={this.renderLabel}
onClick={this.toggleExpanded} renderOptions={this.renderOptions}
> />
<span>Gas Price</span>: {this.props.value} Gwei );
<i className="caret" /> }
</a>
{expanded && private renderLabel = () => {
<ul className="dropdown-menu GasPrice-dropdown-menu"> return `Gas Price: ${this.props.value} Gwei`;
};
private renderOptions = () => {
const { value } = this.props;
return (
<div className="GasPrice-dropdown-menu dropdown-menu dropdown-menu-right">
<div className="GasPrice-header"> <div className="GasPrice-header">
<span>Gas Price</span>: {this.props.value} Gwei <span>Gas Price</span>: {value} Gwei
<input <input
type="range" type="range"
value={this.props.value} value={value}
min={gasPriceDefaults.gasPriceMinGwei} min={gasPriceDefaults.gasPriceMinGwei}
max={gasPriceDefaults.gasPriceMaxGwei} max={gasPriceDefaults.gasPriceMaxGwei}
onChange={this.handleGasPriceChange} onChange={this.handleGasPriceChange}
@ -53,8 +56,8 @@ export default class GasPriceDropdown extends Component<Props, State> {
</p> </p>
<p className="small GasPrice-description"> <p className="small GasPrice-description">
Gas Price is the amount you pay per unit of gas.{' '} Gas Price is the amount you pay per unit of gas.{' '}
<code>TX fee = gas price * gas limit</code> & is paid to miners <code>TX fee = gas price * gas limit</code> & is paid to miners for
for including your TX in a block. Higher the gas price = faster including your TX in a block. Higher the gas price = faster
transaction, but more expensive. Default is <code>21 GWEI</code>. transaction, but more expensive. Default is <code>21 GWEI</code>.
</p> </p>
<p> <p>
@ -67,24 +70,17 @@ export default class GasPriceDropdown extends Component<Props, State> {
</a> </a>
</p> </p>
</div> </div>
</ul>} </div>
</span>
); );
}
public toggleExpanded = () => {
this.setState(state => {
return {
expanded: !state.expanded
};
});
}; };
public updateGasPrice = (value: string) => { private updateGasPrice = (value: string) => {
this.props.onChange(parseInt(value, 10)); 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); this.updateGasPrice((e.target as HTMLInputElement).value);
}; };
} }

View File

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

View File

@ -1,6 +1,6 @@
import { TChangeGasPrice, TChangeLanguage, TChangeNode } from 'actions/config'; import { TChangeGasPrice, TChangeLanguage, TChangeNode } from 'actions/config';
import logo from 'assets/images/logo-myetherwallet.svg'; 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 React, { Component } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { import {
@ -36,7 +36,18 @@ export default class Header extends Component<Props, {}> {
const LanguageDropDown = Dropdown as new () => Dropdown< const LanguageDropDown = Dropdown as new () => Dropdown<
typeof selectedLanguage 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 ( return (
<div className="Header"> <div className="Header">
{ANNOUNCEMENT_MESSAGE && ( {ANNOUNCEMENT_MESSAGE && (
@ -64,37 +75,40 @@ export default class Header extends Component<Props, {}> {
alt="MyEtherWallet" alt="MyEtherWallet"
/> />
</Link> </Link>
<div className="Header-branding-title-tagline"> <div className="Header-branding-right">
<span className="Header-branding-title-tagline-version"> <span className="Header-branding-right-version">v{VERSION}</span>
v{VERSION}
</span>
<div className="Header-branding-right-dropdown">
<GasPriceDropdown <GasPriceDropdown
value={this.props.gasPriceGwei} value={this.props.gasPriceGwei}
onChange={this.props.changeGasPrice} onChange={this.props.changeGasPrice}
/> />
</div>
<div className="Header-branding-right-dropdown">
<LanguageDropDown <LanguageDropDown
ariaLabel={`change language. current language ${languages[ ariaLabel={`change language. current language ${languages[
selectedLanguage selectedLanguage
]}`} ]}`}
options={Object.values(languages)} options={Object.values(languages)}
value={languages[selectedLanguage]} value={languages[selectedLanguage]}
extra={[ extra={
<li key={'separator'} role="separator" className="divider" />, <li key="disclaimer">
<li key={'disclaimer'}>
<a data-toggle="modal" data-target="#disclaimerModal"> <a data-toggle="modal" data-target="#disclaimerModal">
Disclaimer Disclaimer
</a> </a>
</li> </li>
]} }
onChange={this.changeLanguage} onChange={this.changeLanguage}
size="smr"
color="white"
/> />
</div>
<NodeDropDown <div className="Header-branding-right-dropdown">
<ColorDropdown
ariaLabel={`change node. current node ${selectedNode.network} node by ${selectedNode.service}`} ariaLabel={`change node. current node ${selectedNode.network} node by ${selectedNode.service}`}
options={Object.keys(NODES)} options={nodeOptions}
formatTitle={this.nodeNetworkAndService}
value={nodeSelection} value={nodeSelection}
extra={ extra={
<li> <li>
@ -102,8 +116,11 @@ export default class Header extends Component<Props, {}> {
</li> </li>
} }
onChange={changeNode} onChange={changeNode}
size="smr"
color="white"
/> />
</div> </div>
</div>
</section> </section>
</section> </section>
@ -121,10 +138,4 @@ export default class Header extends Component<Props, {}> {
this.props.changeLanguage(key); 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,42 +1,57 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import classnames from 'classnames';
import DropdownShell from './DropdownShell';
interface Props<T> { interface Props<T> {
value: T; value: T;
options: T[]; options: T[];
ariaLabel: string; ariaLabel: string;
label?: string;
extra?: any; extra?: any;
size?: string;
color?: string;
menuAlign?: string;
formatTitle?(option: T): any; formatTitle?(option: T): any;
onChange(value: T): void; onChange(value: T): void;
} }
interface State { export default class DropdownComponent<T> extends Component<Props<T>, {}> {
expanded: boolean; private dropdownShell: DropdownShell | null;
}
export default class DropdownComponent<T> extends Component<Props<T>, State> {
public state = {
expanded: false
};
public render() { public render() {
const { options, value, ariaLabel, extra } = this.props; const { ariaLabel, color, size } = this.props;
const { expanded } = this.state;
return ( return (
<span className={`dropdown ${expanded ? 'open' : ''}`}> <DropdownShell
<a renderLabel={this.renderLabel}
tabIndex={0} renderOptions={this.renderOptions}
aria-haspopup="true" size={size}
aria-expanded="false" color={color}
aria-label={ariaLabel} ariaLabel={ariaLabel}
className="dropdown-toggle" ref={el => (this.dropdownShell = el)}
onClick={this.toggleExpanded} />
> );
{this.props.formatTitle ? this.formatTitle(value) : value} }
<i className="caret" />
</a> private renderLabel = () => {
{expanded && ( const { label, value } = this.props;
<ul className="dropdown-menu"> 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) => { {options.map((option, i) => {
return ( return (
<li key={i}> <li key={i}>
@ -49,29 +64,24 @@ export default class DropdownComponent<T> extends Component<Props<T>, State> {
</li> </li>
); );
})} })}
{extra && <li key={'separator'} role="separator" className="divider" />}
{extra} {extra}
</ul> </ul>
)}
</span>
); );
} };
public formatTitle = (option: any) => { private formatTitle = (option: any) => {
if (this.props.formatTitle) { if (this.props.formatTitle) {
return this.props.formatTitle(option); return this.props.formatTitle(option);
} else {
return option;
} }
}; };
public toggleExpanded = () => { private onChange = (value: any) => {
this.setState(state => {
return {
expanded: !state.expanded
};
});
};
public onChange = (value: any) => {
this.props.onChange(value); 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 Dropdown } from './Dropdown';
export { default as DropdownShell } from './DropdownShell';
export { default as Identicon } from './Identicon'; export { default as Identicon } from './Identicon';
export { default as Modal } from './Modal'; export { default as Modal } from './Modal';
export { default as UnlockHeader } from './UnlockHeader'; export { default as UnlockHeader } from './UnlockHeader';

View File

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

View File

@ -43,6 +43,16 @@
); );
padding: .1rem .6rem .2rem; 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 // Custom color
.btn-white { .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 { .dropdown-menu {
padding: 0; padding: 0;
border: none; border: none;