Improve accessibility (a11y) (#1267)

* Manage modal focus

* Add isOpen prop to CustomNodeModal

* Remove outline overrides

* Update outline style for inputs

* Fix modal focus management & Cleanup CustomNodeModal

* Add aria-label on modal close button

* Fix modal scroll to top

* Add aria-live property for notifications

* Add aria-busy to Spinner component

* Fix border styles for generatewallet password inputs

* Update token balances inputs

* Remove multiple h1's & Update styles

* Add alt text to all img elements

* Update swap link from bity to shapeshift

* Update aria-labels and alt text

* Only show keystore password input when required

* Revert "Only show keystore password input when required"

This reverts commit 7ec5de52da0982cd3131f365b142f6915638d831.

* address changes requested
This commit is contained in:
James Prado 2018-03-08 14:28:43 -05:00 committed by Daniel Ternyak
parent 6e8d807b22
commit 9cac0298a2
50 changed files with 570 additions and 474 deletions

View File

@ -1,36 +1,35 @@
// Mixins
// --------------------------------------------------
// Utilities
@import "mixins/hide-text.less";
@import "mixins/opacity.less";
@import "mixins/image.less";
@import "mixins/labels.less";
@import "mixins/reset-filter.less";
@import "mixins/resize.less";
@import "mixins/responsive-visibility.less";
@import "mixins/size.less";
@import "mixins/tab-focus.less";
@import "mixins/reset-text.less";
@import "mixins/text-emphasis.less";
@import "mixins/text-overflow.less";
@import "mixins/vendor-prefixes.less";
@import 'mixins/hide-text.less';
@import 'mixins/opacity.less';
@import 'mixins/image.less';
@import 'mixins/labels.less';
@import 'mixins/reset-filter.less';
@import 'mixins/resize.less';
@import 'mixins/responsive-visibility.less';
@import 'mixins/size.less';
@import 'mixins/reset-text.less';
@import 'mixins/text-emphasis.less';
@import 'mixins/text-overflow.less';
@import 'mixins/vendor-prefixes.less';
// Components
@import "mixins/alerts.less";
@import "mixins/buttons.less";
@import "mixins/panels.less";
@import "mixins/pagination.less";
@import "mixins/list-group.less";
@import "mixins/nav-divider.less";
@import "mixins/forms.less";
@import "mixins/progress-bar.less";
@import "mixins/table-row.less";
@import 'mixins/alerts.less';
@import 'mixins/buttons.less';
@import 'mixins/panels.less';
@import 'mixins/pagination.less';
@import 'mixins/list-group.less';
@import 'mixins/nav-divider.less';
@import 'mixins/forms.less';
@import 'mixins/progress-bar.less';
@import 'mixins/table-row.less';
// Skins
@import "mixins/background-variant.less";
@import "mixins/border-radius.less";
@import "mixins/gradients.less";
@import 'mixins/background-variant.less';
@import 'mixins/border-radius.less';
@import 'mixins/gradients.less';
// Layout
@import "mixins/clearfix.less";
@import "mixins/center-block.less";
@import "mixins/nav-vertical-align.less";
@import "mixins/grid-framework.less";
@import "mixins/grid.less";
@import 'mixins/clearfix.less';
@import 'mixins/center-block.less';
@import 'mixins/nav-vertical-align.less';
@import 'mixins/grid-framework.less';
@import 'mixins/grid.less';

View File

@ -20,10 +20,10 @@
// Set the border and box shadow on specific inputs to match
.form-control {
border-color: @border-color;
.box-shadow(inset 0 1px 1px rgba(0, 0, 0, .075)); // Redeclare so transitions work
.box-shadow(inset 0 1px 1px rgba(0, 0, 0, 0.075)); // Redeclare so transitions work
&:focus {
border-color: darken(@border-color, 10%);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 3px rgba(@brand-primary, .5);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 3px rgba(@brand-primary, 0.5);
}
}
// Set validation states also for addons
@ -51,11 +51,10 @@
// Example usage: change the default blue border and shadow to white for better
// contrast against a dark gray background.
.form-control-focus(@color: @input-border-focus) {
@color-rgba: rgba(red(@color), green(@color), blue(@color), .6);
@color-rgba: rgba(red(@color), green(@color), blue(@color), 0.6);
&:focus {
border-color: @color;
outline: 0;
.box-shadow(~"inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px @{color-rgba}");
.box-shadow(~'inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px @{color-rgba}');
}
}

View File

@ -1,6 +0,0 @@
// WebKit-style focus
.tab-focus() {
outline: thin dotted;
outline-offset: 3px;
}

View File

@ -7,10 +7,6 @@
position: absolute;
left: 5px;
top: 5px;
&:hover,
&:active {
outline: 0;
}
}
}

View File

@ -18,7 +18,7 @@ export const AmountField: React.SFC<Props> = ({
<AmountFieldFactory
withProps={({ currentValue: { raw }, isValid, onChange, readOnly }) => (
<div className="input-group-wrapper">
<label className="input-group input-group-inline-dropdown">
<label className="input-group input-group-inline">
<div className="input-group-header">{translate('SEND_amount')}</div>
<Input
className={`input-group-input ${

View File

@ -17,7 +17,7 @@ export const Coinbase: React.SFC<Props> = ({ address }) => (
<h5 key="2">Buy ETH with USD</h5>
</div>
<div className="Promos-promo-images">
<img src={CoinbaseLogo} />
<img src={CoinbaseLogo} alt="Coinbase logo" />
</div>
</div>
</NewTabLink>

View File

@ -11,8 +11,8 @@ export const HardwareWallets: React.SFC = () => (
<h6>Learn more about protecting your funds.</h6>
</div>
<div className="Promos-promo-images">
<img src={ledgerLogo} />
<img src={trezorLogo} />
<img src={ledgerLogo} alt="Ledger Logo" />
<img src={trezorLogo} alt="Trezor Logo" />
</div>
</div>
</HelpLink>

View File

@ -13,7 +13,7 @@ export const Shapeshift: React.SFC = () => (
</h5>
</div>
<div className="Promos-promo-images">
<img src={ShapeshiftLogo} />
<img src={ShapeshiftLogo} alt="Shapeshift Logo" />
</div>
</div>
</Link>

View File

@ -85,7 +85,6 @@
height: 12px;
border: 3px solid $gray-lightest;
border-radius: 100%;
outline: none;
opacity: 0.6;
&.is-active {
@ -96,7 +95,7 @@
// Per-promo customizations
&--shapeshift {
background-color: #263A52;
background-color: #263a52;
.Promos-promo-images {
max-width: 130px;

View File

@ -66,11 +66,11 @@ export default class AddCustomTokenForm extends React.PureComponent<Props, State
{fields.map(field => {
return (
<label className="AddCustom-field form-group" key={field.name}>
<span className="AddCustom-field-label">{field.label}</span>
<div className="input-group-header">{field.label}</div>
<Input
className={`${
errors[field.name] ? 'invalid' : field.value ? 'valid' : ''
} AddCustom-field-input input-sm`}
} input-group-input-small`}
type="text"
name={field.name}
value={field.value}

View File

@ -54,6 +54,7 @@ export default class TokenRow extends React.PureComponent<Props, State> {
{!!custom && (
<img
src={removeIcon}
alt="Remove"
className="TokenRow-symbol-remove"
title="Remove Token"
onClick={this.onRemove}

View File

@ -1,5 +1,5 @@
@import "common/sass/variables";
@import "common/sass/mixins";
@import 'common/sass/variables';
@import 'common/sass/mixins';
.BetaAgreement {
@include cover-message;
@ -20,7 +20,6 @@
margin: 0 auto;
border: none;
padding: 0;
outline: none;
transition: $transition;
&.is-continue {

View File

@ -55,13 +55,13 @@ export default class GenerateKeystoreModal extends React.Component<Props, State>
return (
<Modal
title={translate('Generate Keystore File')}
title={translateRaw('Generate Keystore File')}
isOpen={this.props.isOpen}
handleClose={this.handleClose}
>
<form className="GenKeystore" onSubmit={this.handleSubmit}>
<div className="input-group-wrapper GenKeystore-field">
<label className="input-group input-group-inline-dropdown">
<label className="input-group input-group-inline">
<div className="input-group-header">Private Key</div>
<TogglablePassword
name="privateKey"
@ -74,7 +74,7 @@ export default class GenerateKeystoreModal extends React.Component<Props, State>
</label>
</div>
<div className="input-group-wrapper GenKeystore-field">
<label className="input-group input-group-inline-dropdown">
<label className="input-group input-group-inline">
<div className="input-group-header">Password</div>
<TogglablePassword
name="password"

View File

@ -0,0 +1,15 @@
.CustomNodeModal {
.flex-wrapper {
margin: 0px -8px;
> .input-group {
margin: 0px 8px;
> .input-group-input {
width: 100%;
}
}
}
input[type='checkbox'] {
margin-right: 1rem;
margin-bottom: 1rem;
}
}

View File

@ -1,6 +1,6 @@
import React from 'react';
import Modal, { IButton } from 'components/ui/Modal';
import translate from 'translations';
import translate, { translateRaw } from 'translations';
import { CustomNetworkConfig } from 'types/network';
import { CustomNodeConfig } from 'types/node';
import { TAddCustomNetwork, addCustomNetwork, AddCustomNodeAction } from 'actions/config';
@ -13,19 +13,13 @@ import {
} from 'selectors/config';
import { CustomNode } from 'libs/nodes';
import { Input } from 'components/ui';
import Dropdown from 'components/ui/Dropdown';
import './CustomNodeModal.scss';
const CUSTOM = 'custom';
interface InputProps {
name: keyof Omit<State, 'hasAuth'>;
placeholder?: string;
type?: string;
autoComplete?: 'off';
onFocus?(): void;
onBlur?(): void;
}
const CUSTOM = { label: 'Custom', value: 'custom' };
interface OwnProps {
isOpen: boolean;
addCustomNode(payload: AddCustomNodeAction['payload']): void;
handleClose(): void;
}
@ -55,7 +49,7 @@ interface State {
type Props = OwnProps & StateProps & DispatchProps;
class CustomNodeModal extends React.Component<Props, State> {
public state: State = {
public INITIAL_STATE = {
name: '',
url: '',
network: Object.keys(this.props.staticNetworks)[0],
@ -66,9 +60,17 @@ class CustomNodeModal extends React.Component<Props, State> {
username: '',
password: ''
};
public state: State = this.INITIAL_STATE;
public componentDidUpdate(prevProps: Props) {
// Reset state when modal opens
if (!prevProps.isOpen && prevProps.isOpen !== this.props.isOpen) {
this.setState(this.INITIAL_STATE);
}
}
public render() {
const { customNetworks, handleClose, staticNetworks } = this.props;
const { customNetworks, handleClose, staticNetworks, isOpen } = this.props;
const { network } = this.state;
const isHttps = window.location.protocol.includes('https');
const invalids = this.getInvalids();
@ -88,16 +90,21 @@ class CustomNodeModal extends React.Component<Props, State> {
];
const conflictedNode = this.getConflictedNode();
const staticNetwrks = Object.keys(staticNetworks).map(net => {
return { label: net, value: net };
});
const customNetwrks = Object.entries(customNetworks).map(([id, net]) => {
return { label: net.name + ' (Custom)', value: id };
});
const options = [...staticNetwrks, ...customNetwrks, CUSTOM];
return (
<Modal
title={translate('NODE_Title')}
isOpen={true}
title={translateRaw('NODE_Title')}
isOpen={isOpen}
buttons={buttons}
handleClose={handleClose}
maxWidth={580}
>
<div>
{isHttps && <div className="alert alert-warning small">{translate('NODE_Warning')}</div>}
{conflictedNode && (
@ -107,139 +114,128 @@ class CustomNodeModal extends React.Component<Props, State> {
</div>
)}
<form>
<div className="row">
<div className="col-sm-7">
<label>{translate('NODE_Name')}</label>
{this.renderInput(
{
name: 'name',
placeholder: 'My Node'
},
invalids
)}
</div>
<div className="col-sm-5">
<label>Network</label>
<select
className="form-control"
name="network"
<form className="CustomNodeModal">
<div className="flex-wrapper">
<label className="col-sm-9 input-group flex-grow-1">
<div className="input-group-header">Node Name</div>
<Input
className={`input-group-input ${this.state.name && invalids.name ? 'invalid' : ''}`}
type="text"
placeholder="My Node"
value={this.state.name}
onChange={e => this.setState({ name: e.currentTarget.value })}
/>
</label>
<label className="col-sm-3 input-group">
<div className="input-group-header">Network</div>
<Dropdown
className="input-group-dropdown"
value={network}
onChange={this.handleChange}
>
{Object.keys(staticNetworks).map(net => (
<option key={net} value={net}>
{net}
</option>
))}
{Object.entries(customNetworks).map(([id, net]) => (
<option key={id} value={id}>
{net.name} (Custom)
</option>
))}
<option value={CUSTOM}>Custom...</option>
</select>
</div>
options={options}
clearable={false}
onChange={(e: { label: string; value: string }) =>
this.setState({ network: e.value })
}
/>
</label>
</div>
{network === CUSTOM && (
<div className="row">
<div className="col-sm-6">
<label className="is-required">Network Name</label>
{this.renderInput(
{
name: 'customNetworkId',
placeholder: 'My Custom Network'
},
invalids
)}
</div>
<div className="col-sm-3">
<label className="is-required">Currency</label>
{this.renderInput(
{
name: 'customNetworkUnit',
placeholder: 'ETH'
},
invalids
)}
</div>
<div className="col-sm-3">
<label>Chain ID</label>
{this.renderInput(
{
name: 'customNetworkChainId',
placeholder: 'e.g. 1'
},
invalids
)}
</div>
{network === CUSTOM.value && (
<div className="flex-wrapper">
<label className="col-sm-6 input-group input-group-inline">
<div className="input-group-header">Network Name</div>
<Input
className={`input-group-input ${
this.state.customNetworkId && invalids.customNetworkId ? 'invalid' : ''
}`}
type="text"
placeholder="My Custom Network"
value={this.state.customNetworkId}
onChange={e => this.setState({ customNetworkId: e.currentTarget.value })}
/>
</label>
<label className="col-sm-3 input-group input-group-inline">
<div className="input-group-header">Currency</div>
<Input
className={`input-group-input ${
this.state.customNetworkUnit && invalids.customNetworkUnit ? 'invalid' : ''
}`}
type="text"
placeholder="ETH"
value={this.state.customNetworkUnit}
onChange={e => this.setState({ customNetworkUnit: e.currentTarget.value })}
/>
</label>
<label className="col-sm-3 input-group input-group-inline">
<div className="input-group-header">Chain ID</div>
<Input
className={`input-group-input ${
this.state.customNetworkChainId && invalids.customNetworkChainId
? 'invalid'
: ''
}`}
type="text"
placeholder="1"
value={this.state.customNetworkChainId}
onChange={e => this.setState({ customNetworkChainId: e.currentTarget.value })}
/>
</label>
</div>
)}
<div className="row">
<div className="col-sm-12">
<label>URL</label>
{this.renderInput(
{
name: 'url',
placeholder: 'e.g. https://127.0.0.1:8545/',
autoComplete: 'off'
},
invalids
)}
</div>
</div>
<label className="input-group input-group-inline">
<div className="input-group-header">URL</div>
<Input
className={`input-group-input ${this.state.url && invalids.url ? 'invalid' : ''}`}
type="text"
placeholder="https://127.0.0.1:8545/"
value={this.state.url}
onChange={e => this.setState({ url: e.currentTarget.value })}
autoComplete="off"
/>
</label>
<div className="row">
<div className="col-sm-12">
<label>
<input
type="checkbox"
name="hasAuth"
checked={this.state.hasAuth}
onChange={this.handleCheckbox}
/>{' '}
onChange={() => this.setState({ hasAuth: !this.state.hasAuth })}
/>
<span>HTTP Basic Authentication</span>
</label>
</div>
</div>
{this.state.hasAuth && (
<div className="row">
<div className="col-sm-6">
<label className="is-required">Username</label>
{this.renderInput({ name: 'username' }, invalids)}
</div>
<div className="col-sm-6">
<label className="is-required">Password</label>
{this.renderInput(
{
name: 'password',
type: 'password'
},
invalids
)}
</div>
<div className="flex-wrapper ">
<label className="col-sm-6 input-group input-group-inline">
<div className="input-group-header">Username</div>
<Input
className={`input-group-input ${
this.state.username && invalids.username ? 'invalid' : ''
}`}
type="text"
value={this.state.username}
onChange={e => this.setState({ username: e.currentTarget.value })}
/>
</label>
<label className="col-sm-6 input-group input-group-inline">
<div className="input-group-header">Password</div>
<Input
className={`input-group-input ${
this.state.password && invalids.password ? 'invalid' : ''
}`}
type="password"
value={this.state.password}
onChange={e => this.setState({ password: e.currentTarget.value })}
/>
</label>
</div>
)}
</form>
</div>
</Modal>
);
}
private renderInput(input: InputProps, invalids: { [key: string]: boolean }) {
return (
<Input
className={`${this.state[input.name] && invalids[input.name] ? 'invalid' : ''}`}
value={this.state[input.name]}
onChange={this.handleChange}
autoComplete="off"
{...input}
/>
);
}
private getInvalids(): { [key: string]: boolean } {
const {
url,
@ -278,7 +274,7 @@ class CustomNodeModal extends React.Component<Props, State> {
}
// If they have a custom network, make sure info is provided
if (network === CUSTOM) {
if (network === CUSTOM.value) {
if (!customNetworkId) {
invalids.customNetworkId = true;
}
@ -315,7 +311,7 @@ class CustomNodeModal extends React.Component<Props, State> {
const { network, url, name, username, password } = this.state;
const networkId =
network === CUSTOM
network === CUSTOM.value
? this.makeCustomNetworkId(this.makeCustomNetworkConfigFromState())
: network;
@ -348,20 +344,10 @@ class CustomNodeModal extends React.Component<Props, State> {
return customNodes[config.id];
}
private handleChange = (ev: React.FormEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = ev.currentTarget;
this.setState({ [name as any]: value });
};
private handleCheckbox = (ev: React.FormEvent<HTMLInputElement>) => {
const { name } = ev.currentTarget;
this.setState({ [name as any]: !this.state[name as keyof State] });
};
private saveAndAdd = () => {
const node = this.makeCustomNodeConfigFromState();
if (this.state.network === CUSTOM) {
if (this.state.network === CUSTOM.value) {
const network = this.makeCustomNetworkConfigFromState();
this.props.addCustomNetwork({ config: network, id: node.network });

View File

@ -192,12 +192,11 @@ class Header extends Component<Props, State> {
<Navigation color={!network.isCustom && network.color} />
{isAddingCustomNode && (
<CustomNodeModal
isOpen={isAddingCustomNode}
addCustomNode={this.addCustomNode}
handleClose={this.closeCustomNodeModal}
/>
)}
</div>
);
}

View File

@ -100,8 +100,8 @@ export default class PaperWallet extends React.Component<Props, {}> {
return (
<div style={styles.container}>
<img src={sidebarImg} style={styles.sidebar} />
<img src={ethLogo} style={styles.ethLogo} />
<img src={sidebarImg} style={styles.sidebar} alt="MyCrypto Logo" />
<img src={ethLogo} style={styles.ethLogo} alt="ETH Logo" />
<div style={styles.block}>
<div style={styles.box}>
@ -111,7 +111,7 @@ export default class PaperWallet extends React.Component<Props, {}> {
</div>
<div style={styles.block}>
<img src={notesBg} style={styles.box} />
<img src={notesBg} style={styles.box} aria-hidden={true} />
<p style={styles.blockText}>AMOUNT / NOTES</p>
</div>

View File

@ -18,6 +18,7 @@ interface Props {
isValid?: boolean;
isVisible?: boolean;
validity?: 'valid' | 'invalid' | 'semivalid';
readOnly?: boolean;
// Textarea-only props
isTextareaWhenVisible?: boolean;
@ -61,18 +62,16 @@ export default class TogglablePassword extends React.PureComponent<Props, State>
onChange,
onFocus,
onBlur,
handleToggleVisibility
handleToggleVisibility,
readOnly
} = this.props;
const { isVisible } = this.state;
const validClass = validity
? `is-${validity}`
: isValid === null || isValid === undefined ? '' : isValid ? 'is-valid' : 'is-invalid';
return (
<div className={`TogglablePassword input-group input-group-inline-dropdown ${className}`}>
<div className={`TogglablePassword input-group input-group-inline ${className}`}>
{isTextareaWhenVisible && isVisible ? (
<TextArea
className={validClass}
className={validity || !isValid ? 'invalid' : ''}
value={value}
name={name}
disabled={disabled}
@ -83,6 +82,7 @@ export default class TogglablePassword extends React.PureComponent<Props, State>
placeholder={placeholder}
rows={this.props.rows || 3}
aria-label={ariaLabel}
readOnly={readOnly}
/>
) : (
<Input
@ -90,12 +90,13 @@ export default class TogglablePassword extends React.PureComponent<Props, State>
name={name}
disabled={disabled}
type={isVisible ? 'text' : 'password'}
className={`${validClass}`}
className={`${validity || !isValid ? 'invalid' : ''} border-rad-right-0`}
placeholder={placeholder}
onChange={onChange}
onFocus={onFocus}
onBlur={onBlur}
aria-label={ariaLabel}
readOnly={readOnly}
/>
)}
<span

View File

@ -40,7 +40,6 @@ $speed: 500ms;
margin: 0;
}
&:last-child {
margin: 0;
}
@ -80,7 +79,6 @@ $speed: 500ms;
}
&:active {
outline: none;
opacity: 1;
}
@ -106,7 +104,7 @@ $speed: 500ms;
.DecryptContent {
&-enter {
opacity: 0;
transition: opacity $speed * .25 ease $speed * .125;
transition: opacity $speed * 0.25 ease $speed * 0.125;
&-active {
opacity: 1;
@ -119,7 +117,7 @@ $speed: 500ms;
left: 0;
width: 100%;
opacity: 1;
transition: opacity $speed * .25 ease;
transition: opacity $speed * 0.25 ease;
pointer-events: none;
&-active {

View File

@ -29,7 +29,6 @@
transition: transform 150ms ease, box-shadow 150ms ease;
animation: wallet-button-enter 400ms ease 1;
animation-fill-mode: backwards;
outline: none;
@for $i from 0 to 5 {
&:nth-child(#{$i}) {
@ -60,7 +59,6 @@
}
&.is-disabled {
outline: none;
cursor: not-allowed;
@include show-tooltip-on-hover;

View File

@ -28,6 +28,7 @@ interface Icon {
icon: string;
tooltip: string;
href?: string;
arialabel: string;
}
type Props = OwnProps & StateProps;
@ -50,18 +51,21 @@ export class WalletButton extends React.PureComponent<Props> {
if (isReadOnly) {
icons.push({
icon: 'eye',
tooltip: translateRaw('You cannot send using address only')
tooltip: translateRaw('You cannot send using address only'),
arialabel: 'Read Only'
});
} else {
if (isSecure) {
icons.push({
icon: 'shield',
tooltip: translateRaw('This wallet type is secure')
tooltip: translateRaw('This wallet type is secure'),
arialabel: 'Secure wallet type'
});
} else {
icons.push({
icon: 'exclamation-triangle',
tooltip: translateRaw('This wallet type is insecure')
tooltip: translateRaw('This wallet type is insecure'),
arialabel: 'Insecure wallet type'
});
}
}
@ -69,7 +73,8 @@ export class WalletButton extends React.PureComponent<Props> {
icons.push({
icon: 'question-circle',
tooltip: translateRaw('NAV_Help'),
href: helpLink
href: helpLink,
arialabel: 'More info'
});
}
@ -86,22 +91,30 @@ export class WalletButton extends React.PureComponent<Props> {
>
<div className="WalletButton-inner">
<div className="WalletButton-title">
{icon && <img className="WalletButton-title-icon" src={icon} />}
{icon && <img className="WalletButton-title-icon" src={icon} alt={name + ' logo'} />}
<span>{name}</span>
</div>
{description && <div className="WalletButton-description">{description}</div>}
{example && <div className="WalletButton-example">{example}</div>}
{description && (
<div className="WalletButton-description" aria-label="description">
{description}
</div>
)}
{example && (
<div className="WalletButton-example" aria-label="example" aria-hidden={true}>
{example}
</div>
)}
<div className="WalletButton-icons">
{icons.map(i => (
<span className="WalletButton-icons-icon" key={i.icon} onClick={this.stopPropogation}>
{i.href ? (
<NewTabLink href={i.href} onClick={this.stopPropogation}>
<NewTabLink href={i.href} onClick={this.stopPropogation} aria-label={i.arialabel}>
<i className={`fa fa-${i.icon}`} />
</NewTabLink>
) : (
<i className={`fa fa-${i.icon}`} />
<i className={`fa fa-${i.icon}`} aria-label={i.arialabel} />
)}
{!isDisabled && <Tooltip size="sm">{i.tooltip}</Tooltip>}
</span>

View File

@ -92,6 +92,7 @@ export default class ColorDropdown<T> extends PureComponent<Props<T>, {}> {
className="ColorDropdown-item-remove"
onClick={this.onRemove.bind(null, option.onRemove)}
src={removeIcon}
alt="remove"
/>
)}
</a>

View File

@ -12,7 +12,7 @@ interface Props {
const Help = ({ size = 'x1', link }: Props) => {
return (
<a href={link} className={`Help Help-${size}`} target="_blank" rel="noopener noreferrer">
<img src={icon} />
<img src={icon} alt="help" />
</a>
);
};

View File

@ -24,6 +24,7 @@ export default function Identicon(props: Props) {
<React.Fragment>
<img
src={identiconDataUrl}
alt="Unique Address Image"
style={{
height: '100%',
width: '100%',

View File

@ -12,6 +12,22 @@
> .TogglablePassword {
width: 100%;
}
&-inline {
display: flex;
flex-direction: row;
font-size: 1rem;
flex-wrap: wrap;
> .input-group-header {
width: 100%;
}
> .input-group-input {
flex-grow: 1;
width: auto;
}
> .Select {
margin-left: 8px;
}
}
&-header {
display: flex;
font-size: 1rem;
@ -29,6 +45,9 @@
color: rgba(0, 0, 0, 0.54);
}
}
&-dropdown {
margin-bottom: 1rem;
}
&-input {
width: 100%;
border: 1px solid #e5ecf3;
@ -40,6 +59,17 @@
box-shadow: inset 0 1px 0 0 rgba(63, 63, 68, 0.05);
transition: border-color 120ms, box-shadow 120ms;
margin-bottom: 1rem;
&.border-rad-right-0 {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
&.border-rad-left-0 {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
&-small {
padding: 0.5rem 0.75rem;
}
&::placeholder {
color: rgba(0, 0, 0, 0.3);
}
@ -60,23 +90,6 @@
}
}
.input-group-inline-dropdown {
display: flex;
flex-direction: row;
font-size: 1rem;
flex-wrap: wrap;
> .input-group-header {
width: 100%;
}
> .input-group-input {
flex-grow: 1;
width: auto;
}
> .Select {
margin-left: 8px;
}
}
.Swap-dropdown {
.Select-input {
left: 24px;

View File

@ -1,149 +0,0 @@
import closeIcon from 'assets/images/close.svg';
import React, { PureComponent } from 'react';
import { CSSTransition, TransitionGroup } from 'react-transition-group';
import './Modal.scss';
export interface IButton {
text: string | React.ReactElement<string>;
type?: 'default' | 'primary' | 'success' | 'info' | 'warning' | 'danger' | 'link';
disabled?: boolean;
onClick?(): void;
}
interface Props {
isOpen?: boolean;
title?: string | React.ReactElement<any>;
disableButtons?: boolean;
children: any;
buttons?: IButton[];
maxWidth?: number;
handleClose?(): void;
}
interface ModalStyle {
width?: string;
maxWidth?: string;
}
const Fade = ({ children, ...props }: any) => (
<CSSTransition {...props} timeout={300} classNames="animate-modal">
{children}
</CSSTransition>
);
export default class Modal extends PureComponent<Props, {}> {
private modalContent: HTMLElement | null = null;
public componentDidMount() {
this.updateBodyClass();
document.addEventListener('keydown', this.escapeListner);
}
public componentDidUpdate() {
this.updateBodyClass();
}
public updateBodyClass() {
document.body.classList.toggle('no-scroll', !!this.props.isOpen);
}
public componentWillUnmount() {
document.removeEventListener('keydown', this.escapeListner);
document.body.classList.remove('no-scroll');
}
public render() {
const { isOpen, title, children, buttons, handleClose, maxWidth } = this.props;
const hasButtons = buttons && buttons.length;
const modalStyle: ModalStyle = {};
if (maxWidth) {
modalStyle.width = '100%';
modalStyle.maxWidth = `${maxWidth}px`;
}
return (
<TransitionGroup>
{isOpen && (
<Fade>
<div>
<div className="Modalshade" />
<div className="Modal" style={modalStyle}>
{title && (
<div className="Modal-header flex-wrapper">
<h2 className="Modal-header-title">{title}</h2>
<div className="flex-spacer" />
<button className="Modal-header-close" onClick={handleClose}>
<img className="Modal-header-close-icon" src={closeIcon} />
</button>
</div>
)}
<div className="Modal-content" ref={el => (this.modalContent = el)}>
{isOpen && children}
<div className="Modal-fade" />
</div>
{hasButtons && <div className="Modal-footer">{this.renderButtons()}</div>}
</div>
</div>
</Fade>
)}
</TransitionGroup>
);
}
public scrollContentToTop = () => {
if (this.modalContent) {
this.modalContent.scrollTop = 0;
}
};
private escapeListner = (ev: KeyboardEvent) => {
if (!this.props.isOpen) {
return;
}
// Don't trigger if they hit escape while on an input
if (ev.target) {
if (
(ev.target as HTMLElement).tagName === 'INPUT' ||
(ev.target as HTMLElement).tagName === 'SELECT' ||
(ev.target as HTMLElement).tagName === 'TEXTAREA' ||
(ev.target as HTMLElement).isContentEditable
) {
return;
}
}
if (ev.key === 'Escape' || ev.keyCode === 27) {
if (!this.props.handleClose) {
return;
}
this.props.handleClose();
}
};
private renderButtons = () => {
const { disableButtons, buttons } = this.props;
if (!buttons || !buttons.length) {
return;
}
return buttons.map((btn, idx) => {
let btnClass = 'Modal-footer-btn btn';
if (btn.type) {
btnClass += ` btn-${btn.type}`;
}
return (
<button
className={btnClass}
onClick={btn.onClick}
key={idx}
disabled={disableButtons || btn.disabled}
>
{btn.text}
</button>
);
});
};
}

View File

@ -0,0 +1,131 @@
import React, { CSSProperties } from 'react';
import closeIcon from 'assets/images/close.svg';
import { IButton } from 'components/ui/Modal';
interface Props {
title?: string;
children: any;
modalStyle?: CSSProperties;
hasButtons?: number;
buttons?: IButton[];
disableButtons?: any;
handleClose(): void;
}
export default class ModalBody extends React.Component<Props> {
private modal: HTMLElement;
private modalContent: HTMLElement;
private focusedElementBeforeModal: HTMLElement;
private firstTabStop: HTMLElement;
private lastTabStop: HTMLElement;
public componentDidMount() {
this.focusedElementBeforeModal = document.activeElement as HTMLElement;
// Find all focusable children
const focusableElementsString =
'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, [tabindex="0"], [contenteditable]';
const focusableElements = Array.prototype.slice.call(
this.modal.querySelectorAll(focusableElementsString)
);
// Convert NodeList to Array
this.firstTabStop = focusableElements[0];
this.lastTabStop = focusableElements[focusableElements.length - 1];
// Focus first child
this.firstTabStop.focus();
this.modal.addEventListener('keydown', this.keyDownListener);
}
public componentWillUnmount() {
document.removeEventListener('keydown', this.keyDownListener);
}
public scrollContentToTop = () => {
this.modalContent.scrollTop = 0;
};
public render() {
const { title, children, modalStyle, hasButtons, handleClose } = this.props;
return (
<div
className="Modal"
style={modalStyle}
role="dialog"
aria-labelledby="Modal-header-title"
ref={div => {
this.modal = div as HTMLElement;
}}
>
{title && (
<div className="Modal-header flex-wrapper">
<h2 className="Modal-header-title">{title}</h2>
<div className="flex-spacer" />
<button className="Modal-header-close" aria-label="Close" onClick={handleClose}>
<img className="Modal-header-close-icon" src={closeIcon} alt="Close" />
</button>
</div>
)}
<div className="Modal-content" ref={div => (this.modalContent = div as HTMLElement)}>
{children}
<div className="Modal-fade" />
</div>
{hasButtons && <div className="Modal-footer">{this.renderButtons()}</div>}
</div>
);
}
private renderButtons = () => {
const { disableButtons, buttons } = this.props;
if (!buttons || !buttons.length) {
return;
}
return buttons.map((btn, idx: number) => {
let btnClass = 'Modal-footer-btn btn';
if (btn.type) {
btnClass += ` btn-${btn.type}`;
}
return (
<button
className={btnClass}
onClick={btn.onClick}
key={idx}
disabled={disableButtons || btn.disabled}
>
{btn.text}
</button>
);
});
};
private keyDownListener = (e: KeyboardEvent) => {
// Check for TAB key press
if (e.keyCode === 9) {
// SHIFT + TAB
if (e.shiftKey) {
if (document.activeElement === this.firstTabStop) {
e.preventDefault();
this.lastTabStop.focus();
}
// TAB
} else {
if (document.activeElement === this.lastTabStop) {
e.preventDefault();
this.firstTabStop.focus();
}
}
}
// Check for ESC key press
if (e.keyCode === 27) {
this.focusedElementBeforeModal.focus();
this.props.handleClose();
}
};
}

View File

@ -13,17 +13,6 @@ $m-footer-padding: 0.5rem 2rem 1rem 2rem;
$m-close-size: 26px;
$m-anim-speed: 400ms;
.Modalshade {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(#000, 0.54);
z-index: $zindex-modal-background;
display: block;
}
.Modal {
position: fixed;
top: $m-window-padding-h;
@ -45,6 +34,17 @@ $m-anim-speed: 400ms;
box-shadow: 0px 8px 10px -5px rgba(0, 0, 0, 0.2), 0px 16px 24px 2px rgba(0, 0, 0, 0.14),
0px 6px 30px 5px rgba(0, 0, 0, 0.12);
&-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(#000, 0.54);
z-index: $zindex-modal-background;
display: block;
}
&-fade {
background: linear-gradient(to bottom, #fff0, #fff);
position: fixed;

View File

@ -0,0 +1,70 @@
import React, { PureComponent } from 'react';
import { CSSTransition, TransitionGroup } from 'react-transition-group';
import ModalBody from './ModalBody';
import './index.scss';
export interface IButton {
text: string | React.ReactElement<string>;
type?: 'default' | 'primary' | 'success' | 'info' | 'warning' | 'danger' | 'link';
disabled?: boolean;
onClick?(): void;
}
interface Props {
isOpen?: boolean;
title?: string;
disableButtons?: boolean;
children: any;
buttons?: IButton[];
maxWidth?: number;
handleClose(): void;
}
interface ModalStyle {
width?: string;
maxWidth?: string;
}
const Fade = ({ children, ...props }: any) => (
<CSSTransition {...props} timeout={300} classNames="animate-modal">
{children}
</CSSTransition>
);
export default class Modal extends PureComponent<Props, {}> {
public modalBody: ModalBody;
public componentDidUpdate(prevProps: Props) {
if (prevProps.isOpen !== this.props.isOpen) {
document.body.classList.toggle('no-scroll', !!this.props.isOpen);
}
}
public componentWillUnmount() {
document.body.classList.remove('no-scroll');
}
public render() {
const { isOpen, title, children, buttons, handleClose, maxWidth } = this.props;
const hasButtons = buttons && buttons.length;
const modalStyle: ModalStyle = {};
if (maxWidth) {
modalStyle.width = '100%';
modalStyle.maxWidth = `${maxWidth}px`;
}
const modalBodyProps = { title, children, modalStyle, hasButtons, buttons, handleClose };
return (
<TransitionGroup>
{isOpen && (
<Fade>
<div>
<div className="Modal-overlay" onClick={handleClose} />
<ModalBody {...modalBodyProps} ref={div => (this.modalBody = div as ModalBody)} />
</div>
</Fade>
)}
</TransitionGroup>
);
}
}

View File

@ -26,7 +26,7 @@ const OfflineSymbol = ({ offline, size }: OfflineSymbolProps) => {
break;
}
return <img src={offline ? wifiOff : wifiOn} width={width} height={height} />;
return <img src={offline ? wifiOff : wifiOn} alt="wifi status" width={width} height={height} />;
};
export default OfflineSymbol;

View File

@ -35,6 +35,7 @@ export default class QRCode extends React.PureComponent<Props, State> {
return (
<img
src={qr}
alt="QR Code"
style={{
width: '100%',
height: '100%'

View File

@ -11,7 +11,7 @@ interface SpinnerProps {
const Spinner = ({ size = 'x1', light = false }: SpinnerProps) => {
const color = light ? 'Spinner-light' : 'Spinner-dark';
return (
<svg className={`Spinner Spinner-${size} ${color}`} viewBox="0 0 50 50">
<svg className={`Spinner Spinner-${size} ${color}`} viewBox="0 0 50 50" aria-busy="true">
<circle className="path" cx="25" cy="25" r="20" fill="none" strokeWidth="5" />
</svg>
);

View File

@ -8,9 +8,6 @@
padding: 0.4rem 1rem;
border-radius: 2px;
height: 2.5rem;
&:focus {
outline: none;
}
&:active,
&:hover {
opacity: 0.8;

View File

@ -18,7 +18,7 @@ interface Props<T> {
const ValueComp: React.SFC = (props: any) => {
return (
<div className={`${props.className} swap-option-wrapper`}>
<img src={props.value.img} className="swap-option-img" />
<img src={props.value.img} className="swap-option-img" alt={props.value.label + ' logo'} />
<span className="swap-option-label">{props.value.label}</span>
</div>
);
@ -46,7 +46,7 @@ const OptionComp: React.SFC = (props: any) => {
onMouseEnter={handleMouseEnter}
onMouseMove={handleMouseMove}
>
<img src={props.option.img} className="swap-option-img" />
<img src={props.option.img} className="swap-option-img" alt={props.option.label + ' logo'} />
<span className="swap-option-label">{props.option.label}</span>
</div>
);

View File

@ -120,7 +120,11 @@ class OnboardModal extends React.Component<Props, State> {
return (
<div className="OnboardModal">
<Modal isOpen={isOpen} buttons={buttons} ref={el => (this.modal = el)}>
<Modal
isOpen={isOpen}
buttons={buttons}
handleClose={() => (slideNumber === NUMBER_OF_SLIDES ? this.closeModal : null)}
>
<div className="OnboardModal-stepper">
<Stepper
steps={steps}
@ -171,7 +175,7 @@ class OnboardModal extends React.Component<Props, State> {
localStorage.setItem(ONBOARD_LOCAL_STORAGE_KEY, String(prevSlideNum));
this.props.decrementSlide();
if (this.modal) {
this.modal.scrollContentToTop();
this.modal.modalBody.scrollContentToTop();
}
};
@ -180,7 +184,7 @@ class OnboardModal extends React.Component<Props, State> {
localStorage.setItem(ONBOARD_LOCAL_STORAGE_KEY, String(nextSlideNum));
this.props.incrementSlide();
if (this.modal) {
this.modal.scrollContentToTop();
this.modal.modalBody.scrollContentToTop();
}
};
}

View File

@ -14,7 +14,7 @@ interface Props {
export class Notifications extends React.Component<Props, {}> {
public render() {
return (
<TransitionGroup className="Notifications">
<TransitionGroup className="Notifications" aria-live="polite">
{this.props.notifications.map(n => {
return (
<CSSTransition classNames="NotificationAnimation" timeout={500} key={n.id}>

View File

@ -33,10 +33,12 @@ class NameInput extends Component<Props, State> {
return (
<form className="ENSInput" onSubmit={this.onSubmit}>
<div className="input-group-wrapper">
<label className="input-group input-group-inline-dropdown ENSInput-name">
<label className="input-group input-group-inline ENSInput-name">
<Input
value={domainToCheck}
className={!domainToCheck ? '' : isValidDomain ? 'is-valid' : 'is-invalid'}
className={`${
!domainToCheck ? '' : isValidDomain ? '' : 'invalid'
} border-rad-right-0`}
type="text"
placeholder="mycrypto"
onChange={this.onChange}

View File

@ -53,7 +53,11 @@ const CryptoWarning: React.SFC<{}> = () => (
className="CryptoWarning-browsers-browser"
>
<div>
<img className="CryptoWarning-browsers-browser-icon" src={browser.icon} />
<img
className="CryptoWarning-browsers-browser-icon"
src={browser.icon}
alt={browser.name + ' logo'}
/>
<div className="CryptoWarning-browsers-browser-name">{browser.name}</div>
</div>
</NewTabLink>

View File

@ -1,4 +1,4 @@
@import "common/sass/variables";
@import 'common/sass/variables';
.GenPaper {
&-title {
@ -6,8 +6,17 @@
}
&-private {
max-width: 700px;
max-width: 680px;
margin: 0 auto $space * 3;
margin-top: 12px;
> .input-group-header {
margin-bottom: 1rem;
// This selector is an exception, it targets the span returned using `translate`.
> span {
font-size: 2rem;
margin: auto;
}
}
}
&-paper,

View File

@ -1,7 +1,7 @@
import PrintableWallet from 'components/PrintableWallet';
import { IV3Wallet } from 'ethereumjs-wallet';
import React from 'react';
import translate from 'translations';
import translate, { translateRaw } from 'translations';
import { stripHexPrefix } from 'libs/values';
import './PaperWallet.scss';
import Template from '../Template';
@ -17,18 +17,20 @@ const PaperWallet: React.SFC<Props> = props => (
<Template>
<div className="GenPaper">
{/* Private Key */}
<h1 className="GenPaper-title">{translate('GEN_Label_5')}</h1>
<label className="input-group GenPaper-private">
{/* translateRaw isn't used here because it wont properly render the ` characters as a string of code in markdown*/}
<h1 className="input-group-header">{translate('GEN_Label_5')}</h1>
<Input
className="GenPaper-private"
value={stripHexPrefix(props.privateKey)}
aria-label={translate('x_PrivKey', true)}
aria-label={translateRaw('x_PrivKey')}
aria-describedby="x_PrivKeyDesc"
type="text"
readOnly={true}
/>
</label>
{/* Download Paper Wallet */}
<h1 className="GenPaper-title">{translate('x_Print')}</h1>
<h2 className="GenPaper-title">{translate('x_Print')}</h2>
<div className="GenPaper-paper">
<PrintableWallet address={props.keystore.address} privateKey={props.privateKey} />
</div>

View File

@ -28,10 +28,10 @@ export default class MnemonicWord extends React.Component<Props, State> {
return (
<div className="input-group-wrapper MnemonicWord">
<label className="input-group input-group-inline-dropdown ENSInput-name">
<label className="input-group input-group-inline ENSInput-name">
<span className="input-group-addon input-group-addon--transparent">{index + 1}.</span>
<Input
className={classnames('MnemonicWord-word-input', word === value && 'valid')}
className={`MnemonicWord-word-input ${!isReadOnly && 'border-rad-right-0'}`}
value={readOnly ? word : value}
onChange={this.handleChange}
readOnly={readOnly}

View File

@ -14,7 +14,6 @@
top: 36px;
left: 30px;
opacity: 0.3;
outline: none;
color: $text-color;
@media (max-width: $screen-sm) {

View File

@ -341,7 +341,7 @@ export default class CurrencySwap extends PureComponent<Props, State> {
<div className="flex-spacer" />
<div className="input-group-wrapper">
<div className="input-group-header">Deposit</div>
<label className="input-group input-group-inline-dropdown">
<label className="input-group input-group-inline">
<Input
id="origin-swap-input"
className={`input-group-input ${
@ -365,7 +365,7 @@ export default class CurrencySwap extends PureComponent<Props, State> {
</div>
<div className="input-group-wrapper">
<label className="input-group input-group-inline-dropdown">
<label className="input-group input-group-inline">
<div className="input-group-header">Recieve</div>
<Input
id="destination-swap-input"

View File

@ -132,7 +132,7 @@ class CurrentRates extends PureComponent<Props> {
target="_blank"
rel="noopener noreferrer"
>
<img src={providerLogo} width={120} height={49} />
<img src={providerLogo} width={120} height={49} alt="Shapeshift Logo" />
</a>
</section>
</article>

View File

@ -1,7 +1,7 @@
import { RestartSwapAction } from 'actions/swap';
import bityLogo from 'assets/images/logo-bity.svg';
import shapeshiftLogo from 'assets/images/shapeshift-dark.svg';
import { bityReferralURL } from 'config';
import { shapeshiftReferralURL, bitboxReferralURL } from 'config';
import React, { PureComponent } from 'react';
import translate from 'translations';
import './SwapInfoHeader.scss';
@ -29,11 +29,11 @@ export default class SwapInfoHeaderTitle extends PureComponent<SwapInfoHeaderTit
<div className="col-xs-3">
<a
className="SwapInfo-top-logo"
href={bityReferralURL}
href={provider === 'shapeshift' ? shapeshiftReferralURL : bitboxReferralURL}
target="_blank"
rel="noopener noreferrer"
>
<img className="SwapInfo-top-logo-img" src={logoToRender} />
<img className="SwapInfo-top-logo-img" src={logoToRender} alt={provider + ' logo'} />
</a>
</div>
</section>

View File

@ -39,3 +39,8 @@
[data-whatintent='mouse'] *:focus {
outline: none;
}
// This is fine because the outline effect is reproduced with border and box-shadow styles on input elements
input {
outline: none;
}

View File

@ -7,6 +7,19 @@
flex-wrap: nowrap;
}
}
.flex-grow {
&-1 {
flex-grow: 1;
}
&-2 {
flex-grow: 2;
}
&-3 {
flex-grow: 3;
}
}
.flex-spacer {
flex-grow: 2;
}

View File

@ -8,9 +8,3 @@
.btn-group > .btn-group {
float: left;
}
// On active and open, don't show outline
.btn-group .dropdown-toggle:active,
.btn-group.open .dropdown-toggle {
outline: 0;
}

View File

@ -35,7 +35,6 @@ input[readonly] {
&:focus {
border-color: $input-border-focus;
outline: 0;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 1px rgba($brand-primary, 0.5);
}
}

View File

@ -60,6 +60,9 @@
border-bottom-right-radius: 2px;
border-color: inherit;
}
&-menu {
max-height: 8.625rem;
}
&.invalid.has-blurred {
border-color: $brand-danger;
box-shadow: inset 0px 0px 0px 1px $brand-danger;