mirror of
https://github.com/elyby/accounts-frontend.git
synced 2025-05-31 14:11:58 +05:30
Create app namespace for all absolute requires of app modules. Move all packages under packages yarn workspace
This commit is contained in:
125
packages/app/components/ui/Panel.tsx
Normal file
125
packages/app/components/ui/Panel.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { omit } from 'app/functions';
|
||||
|
||||
import styles from './panel.scss';
|
||||
import icons from './icons.scss';
|
||||
|
||||
export function Panel(props: {
|
||||
title?: string;
|
||||
icon?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { title: titleText, icon: iconType } = props;
|
||||
let icon: React.ReactElement | undefined;
|
||||
let title: React.ReactElement | undefined;
|
||||
|
||||
if (iconType) {
|
||||
icon = (
|
||||
<button className={styles.headerControl}>
|
||||
<span className={icons[iconType]} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (titleText) {
|
||||
title = (
|
||||
<PanelHeader>
|
||||
{icon}
|
||||
{titleText}
|
||||
</PanelHeader>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
{title}
|
||||
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PanelHeader(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className={styles.header} {...props}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PanelBody(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className={styles.body} {...props}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PanelFooter(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className={styles.footer} {...props}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export class PanelBodyHeader extends React.Component<
|
||||
{
|
||||
type?: 'default' | 'error';
|
||||
onClose?: () => void;
|
||||
children: React.ReactNode;
|
||||
},
|
||||
{
|
||||
isClosed: boolean;
|
||||
}
|
||||
> {
|
||||
state: {
|
||||
isClosed: boolean;
|
||||
} = {
|
||||
isClosed: false,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { type = 'default', children } = this.props;
|
||||
|
||||
let close;
|
||||
|
||||
if (type === 'error') {
|
||||
close = <span className={styles.close} onClick={this.onClose} />;
|
||||
}
|
||||
|
||||
const className = classNames(styles[`${type}BodyHeader`], {
|
||||
[styles.isClosed]: this.state.isClosed,
|
||||
});
|
||||
|
||||
const extraProps = omit(this.props, ['type', 'onClose']);
|
||||
|
||||
return (
|
||||
<div className={className} {...extraProps}>
|
||||
{close}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onClose = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const { onClose } = this.props;
|
||||
|
||||
this.setState({ isClosed: true });
|
||||
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function PanelIcon({ icon }: { icon: string }) {
|
||||
return (
|
||||
<div className={styles.panelIcon}>
|
||||
<span className={icons[icon]} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
packages/app/components/ui/RelativeTime.tsx
Normal file
20
packages/app/components/ui/RelativeTime.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import { FormattedRelativeTime } from 'react-intl';
|
||||
import { selectUnit } from '@formatjs/intl-utils';
|
||||
|
||||
function RelativeTime({ timestamp }: { timestamp: number }) {
|
||||
const { unit, value }: { unit: any; value: number } = selectUnit(timestamp);
|
||||
|
||||
return (
|
||||
<FormattedRelativeTime
|
||||
value={value}
|
||||
unit={unit}
|
||||
numeric="auto"
|
||||
updateIntervalInSeconds={
|
||||
['seconds', 'minute', 'hour'].includes(unit) ? 1 : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default RelativeTime;
|
||||
5
packages/app/components/ui/bsod/BSoD.intl.json
Normal file
5
packages/app/components/ui/bsod/BSoD.intl.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"criticalErrorHappened": "There was a critical error due to which the application can not continue its normal operation.",
|
||||
"reloadPageOrContactUs": "Please reload this page and try again. If problem occurs again, please report it to the developers by sending email to",
|
||||
"alsoYouCanInteractWithBackground": "You can also play around with the background – it's interactable ;)"
|
||||
}
|
||||
75
packages/app/components/ui/bsod/BSoD.tsx
Normal file
75
packages/app/components/ui/bsod/BSoD.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
import { IntlProvider } from 'app/components/i18n';
|
||||
import logger from 'app/services/logger';
|
||||
import appInfo from 'app/components/auth/appInfo/AppInfo.intl.json';
|
||||
|
||||
import styles from './styles.scss';
|
||||
import BoxesField from './BoxesField';
|
||||
import messages from './BSoD.intl.json';
|
||||
|
||||
interface State {
|
||||
lastEventId?: string | void;
|
||||
}
|
||||
|
||||
// TODO: probably it is better to render this view from the App view
|
||||
// to remove dependencies from store and IntlProvider
|
||||
class BSoD extends React.Component<{}, State> {
|
||||
state: State = {};
|
||||
|
||||
componentDidMount() {
|
||||
// poll for event id
|
||||
const timer = setInterval(() => {
|
||||
if (!logger.getLastEventId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearInterval(timer);
|
||||
|
||||
this.setState({
|
||||
lastEventId: logger.getLastEventId(),
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { lastEventId } = this.state;
|
||||
|
||||
let emailUrl = 'mailto:support@ely.by';
|
||||
|
||||
if (lastEventId) {
|
||||
emailUrl += `?subject=Bug report for #${lastEventId}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<IntlProvider>
|
||||
<div className={styles.body}>
|
||||
<canvas
|
||||
className={styles.canvas}
|
||||
ref={(el: HTMLCanvasElement | null) => el && new BoxesField(el)}
|
||||
/>
|
||||
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.title}>
|
||||
<Message {...appInfo.appName} />
|
||||
</div>
|
||||
<div className={styles.lineWithMargin}>
|
||||
<Message {...messages.criticalErrorHappened} />
|
||||
</div>
|
||||
<div className={styles.line}>
|
||||
<Message {...messages.reloadPageOrContactUs} />
|
||||
</div>
|
||||
<a href={emailUrl} className={styles.support}>
|
||||
support@ely.by
|
||||
</a>
|
||||
<div className={styles.easterEgg}>
|
||||
<Message {...messages.alsoYouCanInteractWithBackground} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</IntlProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default BSoD;
|
||||
100
packages/app/components/ui/bsod/Box.js
Normal file
100
packages/app/components/ui/bsod/Box.js
Normal file
@@ -0,0 +1,100 @@
|
||||
export default class Box {
|
||||
constructor({ size, startX, startY, startRotate, color, shadowColor }) {
|
||||
this.color = color;
|
||||
this.shadowColor = shadowColor;
|
||||
this.halfSize = 0;
|
||||
this.setSize(size);
|
||||
this.x = startX;
|
||||
this.y = startY;
|
||||
this.angle = startRotate;
|
||||
this.shadowLength = 2000; // TODO: should be calculated
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this._initialSize;
|
||||
}
|
||||
|
||||
get dots() {
|
||||
const full = (Math.PI * 2) / 4;
|
||||
|
||||
const p1 = {
|
||||
x: this.x + this.halfSize * Math.sin(this.angle),
|
||||
y: this.y + this.halfSize * Math.cos(this.angle),
|
||||
};
|
||||
|
||||
const p2 = {
|
||||
x: this.x + this.halfSize * Math.sin(this.angle + full),
|
||||
y: this.y + this.halfSize * Math.cos(this.angle + full),
|
||||
};
|
||||
|
||||
const p3 = {
|
||||
x: this.x + this.halfSize * Math.sin(this.angle + full * 2),
|
||||
y: this.y + this.halfSize * Math.cos(this.angle + full * 2),
|
||||
};
|
||||
|
||||
const p4 = {
|
||||
x: this.x + this.halfSize * Math.sin(this.angle + full * 3),
|
||||
y: this.y + this.halfSize * Math.cos(this.angle + full * 3),
|
||||
};
|
||||
|
||||
return { p1, p2, p3, p4 };
|
||||
}
|
||||
|
||||
rotate() {
|
||||
const speed = (60 - this.halfSize) / 20;
|
||||
this.angle += speed * 0.002;
|
||||
this.x += speed;
|
||||
this.y += speed;
|
||||
}
|
||||
|
||||
draw(ctx) {
|
||||
const { dots } = this;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(dots.p1.x, dots.p1.y);
|
||||
ctx.lineTo(dots.p2.x, dots.p2.y);
|
||||
ctx.lineTo(dots.p3.x, dots.p3.y);
|
||||
ctx.lineTo(dots.p4.x, dots.p4.y);
|
||||
ctx.fillStyle = this.color;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
drawShadow(ctx, light) {
|
||||
const { dots } = this;
|
||||
const angles = [];
|
||||
const points = [];
|
||||
|
||||
for (const i in dots) {
|
||||
if (!dots.hasOwnProperty(i)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dot = dots[i];
|
||||
const angle = Math.atan2(light.y - dot.y, light.x - dot.x);
|
||||
const endX = dot.x + this.shadowLength * Math.sin(-angle - Math.PI / 2);
|
||||
const endY = dot.y + this.shadowLength * Math.cos(-angle - Math.PI / 2);
|
||||
angles.push(angle);
|
||||
points.push({
|
||||
endX,
|
||||
endY,
|
||||
startX: dot.x,
|
||||
startY: dot.y,
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = points.length - 1; i >= 0; i--) {
|
||||
const n = i === 3 ? 0 : i + 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(points[i].startX, points[i].startY);
|
||||
ctx.lineTo(points[n].startX, points[n].startY);
|
||||
ctx.lineTo(points[n].endX, points[n].endY);
|
||||
ctx.lineTo(points[i].endX, points[i].endY);
|
||||
ctx.fillStyle = this.shadowColor;
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
setSize(size) {
|
||||
this._initialSize = size;
|
||||
this.halfSize = Math.floor(size / 2);
|
||||
}
|
||||
}
|
||||
158
packages/app/components/ui/bsod/BoxesField.js
Normal file
158
packages/app/components/ui/bsod/BoxesField.js
Normal file
@@ -0,0 +1,158 @@
|
||||
import Box from './Box';
|
||||
|
||||
/**
|
||||
* Основано на http://codepen.io/mladen___/pen/gbvqBo
|
||||
*/
|
||||
export default class BoxesField {
|
||||
/**
|
||||
* @param {HTMLCanvasElement} elem - canvas DOM node
|
||||
* @param {object} params
|
||||
*/
|
||||
constructor(
|
||||
elem,
|
||||
params = {
|
||||
countBoxes: 14,
|
||||
boxMinSize: 20,
|
||||
boxMaxSize: 75,
|
||||
backgroundColor: '#233d49',
|
||||
lightColor: '#28555b',
|
||||
shadowColor: '#274451',
|
||||
boxColors: [
|
||||
'#207e5c',
|
||||
'#5b9aa9',
|
||||
'#e66c69',
|
||||
'#6b5b8c',
|
||||
'#8b5d79',
|
||||
'#dd8650',
|
||||
],
|
||||
},
|
||||
) {
|
||||
this.elem = elem;
|
||||
const ctx = elem.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('Can not get canvas 2d context');
|
||||
}
|
||||
|
||||
this.ctx = ctx;
|
||||
this.params = params;
|
||||
|
||||
this.light = {
|
||||
x: 160,
|
||||
y: 200,
|
||||
};
|
||||
|
||||
this.resize();
|
||||
this.drawLoop();
|
||||
this.bindWindowListeners();
|
||||
|
||||
/**
|
||||
* @type {Box[]}
|
||||
*/
|
||||
this.boxes = [];
|
||||
|
||||
while (this.boxes.length < this.params.countBoxes) {
|
||||
this.boxes.push(
|
||||
new Box({
|
||||
size: Math.floor(
|
||||
Math.random() * (this.params.boxMaxSize - this.params.boxMinSize) +
|
||||
this.params.boxMinSize,
|
||||
),
|
||||
startX: Math.floor(Math.random() * elem.width + 1),
|
||||
startY: Math.floor(Math.random() * elem.height + 1),
|
||||
startRotate: Math.random() * Math.PI,
|
||||
color: this.getRandomColor(),
|
||||
shadowColor: this.params.shadowColor,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
resize() {
|
||||
const { width, height } = this.elem.getBoundingClientRect();
|
||||
this.elem.width = width;
|
||||
this.elem.height = height;
|
||||
}
|
||||
|
||||
drawLight(light) {
|
||||
const greaterSize =
|
||||
window.screen.width > window.screen.height
|
||||
? window.screen.width
|
||||
: window.screen.height;
|
||||
// еее, теорема пифагора и описывание окружности вокруг квадрата, не зря в универ ходил!!!
|
||||
const lightRadius = greaterSize * Math.sqrt(2);
|
||||
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(light.x, light.y, lightRadius, 0, 2 * Math.PI);
|
||||
const gradient = this.ctx.createRadialGradient(
|
||||
light.x,
|
||||
light.y,
|
||||
0,
|
||||
light.x,
|
||||
light.y,
|
||||
lightRadius,
|
||||
);
|
||||
gradient.addColorStop(0, this.params.lightColor);
|
||||
gradient.addColorStop(1, this.params.backgroundColor);
|
||||
this.ctx.fillStyle = gradient;
|
||||
this.ctx.fill();
|
||||
}
|
||||
|
||||
drawLoop() {
|
||||
this.ctx.clearRect(0, 0, this.elem.width, this.elem.height);
|
||||
this.drawLight(this.light);
|
||||
|
||||
for (const i in this.boxes) {
|
||||
if (!this.boxes.hasOwnProperty(i)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const box = this.boxes[i];
|
||||
box.rotate();
|
||||
box.drawShadow(this.ctx, this.light);
|
||||
}
|
||||
|
||||
for (const i in this.boxes) {
|
||||
if (!this.boxes.hasOwnProperty(i)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const box = this.boxes[i];
|
||||
box.draw(this.ctx);
|
||||
|
||||
// Если квадратик вылетел за пределы экрана
|
||||
if (box.y - box.halfSize > this.elem.height) {
|
||||
box.y -= this.elem.height + 100;
|
||||
this.updateBox(box);
|
||||
}
|
||||
|
||||
if (box.x - box.halfSize > this.elem.width) {
|
||||
box.x -= this.elem.width + 100;
|
||||
this.updateBox(box);
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(this.drawLoop.bind(this));
|
||||
}
|
||||
|
||||
bindWindowListeners() {
|
||||
window.addEventListener('resize', this.resize.bind(this));
|
||||
window.addEventListener('mousemove', event => {
|
||||
this.light.x = event.clientX;
|
||||
this.light.y = event.clientY;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Box} box
|
||||
*/
|
||||
updateBox(box) {
|
||||
box.color = this.getRandomColor();
|
||||
}
|
||||
|
||||
getRandomColor() {
|
||||
return this.params.boxColors[
|
||||
Math.floor(Math.random() * this.params.boxColors.length)
|
||||
];
|
||||
}
|
||||
}
|
||||
49
packages/app/components/ui/bsod/BsodMiddleware.test.ts
Normal file
49
packages/app/components/ui/bsod/BsodMiddleware.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import expect from 'app/test/unexpected';
|
||||
import sinon from 'sinon';
|
||||
|
||||
import BsodMiddleware from 'app/components/ui/bsod/BsodMiddleware';
|
||||
|
||||
describe('BsodMiddleware', () => {
|
||||
[500, 503, 555].forEach(code =>
|
||||
it(`should dispatch for ${code}`, () => {
|
||||
const resp = {
|
||||
originalResponse: { status: code },
|
||||
};
|
||||
|
||||
const dispatchBsod = sinon.spy();
|
||||
const logger = { warn: sinon.spy() };
|
||||
|
||||
const middleware = new BsodMiddleware(dispatchBsod, logger as any);
|
||||
|
||||
return expect(middleware.catch(resp), 'to be rejected with', resp).then(
|
||||
() => {
|
||||
expect(dispatchBsod, 'was called');
|
||||
expect(logger.warn, 'to have a call satisfying', [
|
||||
'Unexpected response (BSoD)',
|
||||
{ resp },
|
||||
]);
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
[200, 404].forEach(code =>
|
||||
it(`should not dispatch for ${code}`, () => {
|
||||
const resp = {
|
||||
originalResponse: { status: code },
|
||||
};
|
||||
|
||||
const dispatchBsod = sinon.spy();
|
||||
const logger = { warn: sinon.spy() };
|
||||
|
||||
const middleware = new BsodMiddleware(dispatchBsod, logger as any);
|
||||
|
||||
return expect(middleware.catch(resp), 'to be rejected with', resp).then(
|
||||
() => {
|
||||
expect(dispatchBsod, 'was not called');
|
||||
expect(logger.warn, 'was not called');
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
49
packages/app/components/ui/bsod/BsodMiddleware.ts
Normal file
49
packages/app/components/ui/bsod/BsodMiddleware.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { InternalServerError } from 'app/services/request';
|
||||
import { Resp, Middleware } from 'app/services/request';
|
||||
import defaultLogger from 'app/services/logger';
|
||||
|
||||
type Logger = typeof defaultLogger;
|
||||
|
||||
const ABORT_ERR = 20;
|
||||
|
||||
class BsodMiddleware implements Middleware {
|
||||
dispatchBsod: () => any;
|
||||
logger: Logger;
|
||||
|
||||
constructor(dispatchBsod: () => any, logger: Logger = defaultLogger) {
|
||||
this.dispatchBsod = dispatchBsod;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
async catch<T extends Resp<any>>(
|
||||
resp?: T | InternalServerError | Error,
|
||||
): Promise<T> {
|
||||
const { originalResponse }: { originalResponse?: Resp<any> } = (resp ||
|
||||
{}) as InternalServerError;
|
||||
|
||||
if (
|
||||
resp &&
|
||||
((resp instanceof InternalServerError &&
|
||||
(resp.error as any).code !== ABORT_ERR) ||
|
||||
(originalResponse && /5\d\d/.test(originalResponse.status)))
|
||||
) {
|
||||
this.dispatchBsod();
|
||||
|
||||
const { message: errorMessage } = resp as { [key: string]: any };
|
||||
|
||||
if (!errorMessage || !/NetworkError/.test(errorMessage)) {
|
||||
let message = 'Unexpected response (BSoD)';
|
||||
|
||||
if (errorMessage) {
|
||||
message = `BSoD: ${errorMessage}`;
|
||||
}
|
||||
|
||||
this.logger.warn(message, { resp });
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(resp);
|
||||
}
|
||||
}
|
||||
|
||||
export default BsodMiddleware;
|
||||
10
packages/app/components/ui/bsod/actions.js
Normal file
10
packages/app/components/ui/bsod/actions.js
Normal file
@@ -0,0 +1,10 @@
|
||||
export const BSOD = 'BSOD';
|
||||
|
||||
/**
|
||||
* @returns {object}
|
||||
*/
|
||||
export function bsod() {
|
||||
return {
|
||||
type: BSOD,
|
||||
};
|
||||
}
|
||||
20
packages/app/components/ui/bsod/dispatchBsod.js
Normal file
20
packages/app/components/ui/bsod/dispatchBsod.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import { bsod } from './actions';
|
||||
import BSoD from 'app/components/ui/bsod/BSoD';
|
||||
|
||||
let injectedStore;
|
||||
let onBsod;
|
||||
|
||||
export default function dispatchBsod(store = injectedStore) {
|
||||
store.dispatch(bsod());
|
||||
onBsod && onBsod();
|
||||
|
||||
ReactDOM.render(<BSoD />, document.getElementById('app'));
|
||||
}
|
||||
|
||||
export function inject(store, stopLoading) {
|
||||
injectedStore = store;
|
||||
onBsod = stopLoading;
|
||||
}
|
||||
12
packages/app/components/ui/bsod/factory.js
Normal file
12
packages/app/components/ui/bsod/factory.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import request from 'app/services/request';
|
||||
import logger from 'app/services/logger';
|
||||
|
||||
import dispatchBsod, { inject } from './dispatchBsod';
|
||||
import BsodMiddleware from './BsodMiddleware';
|
||||
|
||||
export default function factory(store, stopLoading) {
|
||||
inject(store, stopLoading);
|
||||
|
||||
// do bsod for 500/404 errors
|
||||
request.addMiddleware(new BsodMiddleware(dispatchBsod, logger));
|
||||
}
|
||||
9
packages/app/components/ui/bsod/reducer.js
Normal file
9
packages/app/components/ui/bsod/reducer.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { BSOD } from './actions';
|
||||
|
||||
export default function(state = false, { type }) {
|
||||
if (type === BSOD) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
57
packages/app/components/ui/bsod/styles.scss
Normal file
57
packages/app/components/ui/bsod/styles.scss
Normal file
@@ -0,0 +1,57 @@
|
||||
@import '~app/components/ui/colors.scss';
|
||||
|
||||
$font-family-monospaced: 'Consolas', 'Menlo', 'DejaVu Sans Mono', 'Roboto Mono',
|
||||
monospace;
|
||||
|
||||
.body {
|
||||
height: 100%;
|
||||
background-color: $dark_blue;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-family: $font-family-monospaced;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
margin: 85px auto 0;
|
||||
max-width: 500px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 26px;
|
||||
margin-bottom: 13px;
|
||||
}
|
||||
|
||||
.line {
|
||||
margin: 0 auto;
|
||||
font-size: 16px;
|
||||
color: #ebe8e1;
|
||||
}
|
||||
|
||||
.lineWithMargin {
|
||||
composes: line;
|
||||
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.support {
|
||||
font-size: 18px;
|
||||
color: #fff;
|
||||
margin: 3px 0 44px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.easterEgg {
|
||||
font-size: 14px;
|
||||
color: #ebe8e1;
|
||||
}
|
||||
20
packages/app/components/ui/button-groups.scss
Normal file
20
packages/app/components/ui/button-groups.scss
Normal file
@@ -0,0 +1,20 @@
|
||||
.horizontalGroup {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.item {
|
||||
// TODO: in some cases we do not need overflow hidden
|
||||
// probably, it is better to create a separate class for children, that will
|
||||
// enable overflow hidden and ellipsis
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
|
||||
$borderConfig: 1px solid rgba(#fff, 0.15);
|
||||
|
||||
border-left: $borderConfig;
|
||||
|
||||
&:last-child {
|
||||
border-right: $borderConfig;
|
||||
}
|
||||
}
|
||||
98
packages/app/components/ui/buttons.scss
Normal file
98
packages/app/components/ui/buttons.scss
Normal file
@@ -0,0 +1,98 @@
|
||||
@import './colors.scss';
|
||||
@import './fonts.scss';
|
||||
|
||||
@mixin button-theme($themeName, $backgroundColor) {
|
||||
.#{$themeName} {
|
||||
composes: button;
|
||||
|
||||
background-color: $backgroundColor;
|
||||
|
||||
&:hover {
|
||||
background-color: lighter($backgroundColor);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: darker($backgroundColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
height: 50px;
|
||||
padding: 0 15px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
|
||||
font-family: $font-family-title;
|
||||
color: $defaultButtonTextColor;
|
||||
font-size: 18px;
|
||||
line-height: 50px;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
|
||||
transition: 0.25s;
|
||||
|
||||
&:hover {
|
||||
color: $defaultButtonTextColor;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.smallButton {
|
||||
composes: button;
|
||||
|
||||
height: 30px;
|
||||
padding: 0 15px;
|
||||
font-size: 14px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
.white {
|
||||
composes: button;
|
||||
|
||||
background-color: #fff;
|
||||
color: #444;
|
||||
|
||||
&:hover {
|
||||
color: #262626;
|
||||
background-color: $whiteButtonLight;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: $whiteButtonDark;
|
||||
}
|
||||
}
|
||||
|
||||
@include button-theme('black', $black);
|
||||
@include button-theme('blue', $blue);
|
||||
@include button-theme('green', $green);
|
||||
@include button-theme('orange', $orange);
|
||||
@include button-theme('darkBlue', $dark_blue);
|
||||
@include button-theme('lightViolet', $light_violet);
|
||||
@include button-theme('violet', $violet);
|
||||
@include button-theme('red', $red);
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loading {
|
||||
background: url('./form/images/loader_button.gif') #95a5a6 center center !important;
|
||||
|
||||
cursor: default;
|
||||
color: #fff;
|
||||
transition: 0.25s;
|
||||
outline: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.black.disabled {
|
||||
background: #95a5a6;
|
||||
cursor: default;
|
||||
}
|
||||
85
packages/app/components/ui/collapse/Collapse.tsx
Normal file
85
packages/app/components/ui/collapse/Collapse.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Motion, spring } from 'react-motion';
|
||||
import MeasureHeight from 'app/components/MeasureHeight';
|
||||
|
||||
import styles from './collapse.scss';
|
||||
|
||||
type Props = {
|
||||
isOpened?: boolean;
|
||||
children: React.ReactNode;
|
||||
onRest: () => void;
|
||||
};
|
||||
|
||||
export default class Collapse extends Component<
|
||||
Props,
|
||||
{
|
||||
height: number;
|
||||
wasInitialized: boolean;
|
||||
}
|
||||
> {
|
||||
state = {
|
||||
height: 0,
|
||||
wasInitialized: false,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
onRest: () => {},
|
||||
};
|
||||
|
||||
componentWillReceiveProps(nextProps: Props) {
|
||||
if (
|
||||
this.props.isOpened !== nextProps.isOpened &&
|
||||
!this.state.wasInitialized
|
||||
) {
|
||||
this.setState({
|
||||
wasInitialized: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isOpened, children, onRest } = this.props;
|
||||
const { height, wasInitialized } = this.state;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.overflow}
|
||||
style={wasInitialized ? {} : { height: 0 }}
|
||||
>
|
||||
<MeasureHeight
|
||||
state={this.shouldMeasureHeight()}
|
||||
onMeasure={this.onUpdateHeight}
|
||||
>
|
||||
<Motion
|
||||
style={{
|
||||
top: wasInitialized ? spring(isOpened ? 0 : -height) : -height,
|
||||
}}
|
||||
onRest={onRest}
|
||||
>
|
||||
{({ top }) => (
|
||||
<div
|
||||
className={styles.content}
|
||||
style={{
|
||||
marginTop: top,
|
||||
visibility: wasInitialized ? 'inherit' : 'hidden',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
</MeasureHeight>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onUpdateHeight = (height: number) => {
|
||||
this.setState({
|
||||
height,
|
||||
});
|
||||
};
|
||||
|
||||
shouldMeasureHeight = () => {
|
||||
return [this.props.isOpened, this.state.wasInitialized].join('');
|
||||
};
|
||||
}
|
||||
8
packages/app/components/ui/collapse/collapse.scss
Normal file
8
packages/app/components/ui/collapse/collapse.scss
Normal file
@@ -0,0 +1,8 @@
|
||||
@import '~app/components/ui/colors.scss';
|
||||
|
||||
.overflow {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content {
|
||||
}
|
||||
1
packages/app/components/ui/collapse/index.ts
Normal file
1
packages/app/components/ui/collapse/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Collapse';
|
||||
52
packages/app/components/ui/colors.scss
Normal file
52
packages/app/components/ui/colors.scss
Normal file
@@ -0,0 +1,52 @@
|
||||
$green: #207e5c;
|
||||
$blue: #5b9aa9;
|
||||
$red: #e66c69;
|
||||
$violet: #6b5b8c;
|
||||
$dark_blue: #28555b;
|
||||
$light_violet: #8b5d79;
|
||||
$orange: #dd8650;
|
||||
$white: #ebe8e1;
|
||||
|
||||
$black: #232323;
|
||||
|
||||
$defaultButtonTextColor: #fff;
|
||||
$whiteButtonLight: #f5f5f5;
|
||||
$whiteButtonDark: #f5f5f5; // TODO: найти оптимальный цвет для прожатого состояния
|
||||
|
||||
@function darker($color) {
|
||||
$elyColorsMap: (
|
||||
$green: #1a6449,
|
||||
$blue: #568297,
|
||||
$red: #e15457,
|
||||
$violet: #66437a,
|
||||
$dark_blue: #233d49,
|
||||
$light_violet: #864567,
|
||||
$orange: #d86e3e,
|
||||
$black: #1c1c1c,
|
||||
);
|
||||
|
||||
@if (map_has_key($elyColorsMap, $color)) {
|
||||
@return map-get($elyColorsMap, $color);
|
||||
} @else {
|
||||
@return darken($color, 4%);
|
||||
}
|
||||
}
|
||||
|
||||
@function lighter($color) {
|
||||
$elyColorsMap: (
|
||||
$green: #379070,
|
||||
$blue: #71a6b2,
|
||||
$red: #fc7872,
|
||||
$violet: #816795,
|
||||
$dark_blue: #3e6164,
|
||||
$light_violet: #a16982,
|
||||
$orange: #f39259,
|
||||
$black: #2e2e2e,
|
||||
);
|
||||
|
||||
@if (map_has_key($elyColorsMap, $color)) {
|
||||
@return map-get($elyColorsMap, $color);
|
||||
} @else {
|
||||
@return lighten($color, 3%);
|
||||
}
|
||||
}
|
||||
4
packages/app/components/ui/fonts.scss
Normal file
4
packages/app/components/ui/fonts.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
$font-family-title: 'Roboto Condensed', Arial, sans-serif;
|
||||
$font-family-base: 'Roboto', Arial, sans-serif;
|
||||
|
||||
$font-weight-bold: 500;
|
||||
57
packages/app/components/ui/form/Button.tsx
Normal file
57
packages/app/components/ui/form/Button.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import buttons from 'app/components/ui/buttons.scss';
|
||||
import { COLOR_GREEN } from 'app/components/ui';
|
||||
import { MessageDescriptor } from 'react-intl';
|
||||
import { Color } from 'app/components/ui';
|
||||
|
||||
import FormComponent from './FormComponent';
|
||||
|
||||
export default class Button extends FormComponent<
|
||||
{
|
||||
// TODO: drop MessageDescriptor support. It should be React.ReactNode only
|
||||
label: string | MessageDescriptor | React.ReactElement;
|
||||
block?: boolean;
|
||||
small?: boolean;
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
color?: Color;
|
||||
disabled?: boolean;
|
||||
component?: string | React.ComponentType<any>;
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
> {
|
||||
render() {
|
||||
const {
|
||||
color = COLOR_GREEN,
|
||||
block,
|
||||
small,
|
||||
disabled,
|
||||
className,
|
||||
loading,
|
||||
label,
|
||||
component: ComponentProp = 'button',
|
||||
...restProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<ComponentProp
|
||||
className={classNames(
|
||||
buttons[color],
|
||||
{
|
||||
[buttons.loading]: loading,
|
||||
[buttons.block]: block,
|
||||
[buttons.smallButton]: small,
|
||||
[buttons.disabled]: disabled,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
disabled={disabled}
|
||||
{...restProps}
|
||||
>
|
||||
{typeof label === 'object' && React.isValidElement(label)
|
||||
? label
|
||||
: this.formatMessage(label)}
|
||||
</ComponentProp>
|
||||
);
|
||||
}
|
||||
}
|
||||
82
packages/app/components/ui/form/Captcha.tsx
Normal file
82
packages/app/components/ui/form/Captcha.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { CaptchaID } from 'app/services/captcha';
|
||||
import { Skin } from 'app/components/ui';
|
||||
import captcha from 'app/services/captcha';
|
||||
import logger from 'app/services/logger';
|
||||
import { ComponentLoader } from 'app/components/ui/loader';
|
||||
|
||||
import styles from './form.scss';
|
||||
import FormInputComponent from './FormInputComponent';
|
||||
|
||||
export default class Captcha extends FormInputComponent<
|
||||
{
|
||||
delay: number;
|
||||
skin: Skin;
|
||||
},
|
||||
{
|
||||
code: string;
|
||||
}
|
||||
> {
|
||||
elRef = React.createRef<HTMLDivElement>();
|
||||
captchaId: CaptchaID;
|
||||
|
||||
static defaultProps = {
|
||||
skin: 'dark',
|
||||
delay: 0,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
setTimeout(() => {
|
||||
const { current: el } = this.elRef;
|
||||
|
||||
el &&
|
||||
captcha
|
||||
.render(el, {
|
||||
skin: this.props.skin,
|
||||
onSetCode: this.setCode,
|
||||
})
|
||||
.then(captchaId => {
|
||||
this.captchaId = captchaId;
|
||||
})
|
||||
.catch(error => {
|
||||
logger.error('Failed rendering captcha', {
|
||||
error,
|
||||
});
|
||||
});
|
||||
}, this.props.delay);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { skin } = this.props;
|
||||
|
||||
return (
|
||||
<div className={styles.captchaContainer}>
|
||||
<div className={styles.captchaLoader}>
|
||||
<ComponentLoader />
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={this.elRef}
|
||||
className={classNames(styles.captcha, styles[`${skin}Captcha`])}
|
||||
/>
|
||||
|
||||
{this.renderError()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
reset() {
|
||||
captcha.reset(this.captchaId);
|
||||
}
|
||||
|
||||
getValue() {
|
||||
return this.state && this.state.code;
|
||||
}
|
||||
|
||||
onFormInvalid() {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
setCode = (code: string) => this.setState({ code });
|
||||
}
|
||||
63
packages/app/components/ui/form/Checkbox.tsx
Normal file
63
packages/app/components/ui/form/Checkbox.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import { MessageDescriptor } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
import { SKIN_DARK, COLOR_GREEN, Color, Skin } from 'app/components/ui';
|
||||
import { omit } from 'app/functions';
|
||||
|
||||
import styles from './form.scss';
|
||||
import FormInputComponent from './FormInputComponent';
|
||||
|
||||
export default class Checkbox extends FormInputComponent<{
|
||||
color: Color;
|
||||
skin: Skin;
|
||||
label: string | MessageDescriptor;
|
||||
}> {
|
||||
static defaultProps = {
|
||||
color: COLOR_GREEN,
|
||||
skin: SKIN_DARK,
|
||||
};
|
||||
|
||||
elRef = React.createRef<HTMLInputElement>();
|
||||
|
||||
render() {
|
||||
const { color, skin } = this.props;
|
||||
let { label } = this.props;
|
||||
|
||||
label = this.formatMessage(label);
|
||||
|
||||
const props = omit(this.props, ['color', 'skin', 'label']);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
styles[`${color}MarkableRow`],
|
||||
styles[`${skin}MarkableRow`],
|
||||
)}
|
||||
>
|
||||
<label className={styles.markableContainer}>
|
||||
<input
|
||||
ref={this.elRef}
|
||||
className={styles.markableInput}
|
||||
type="checkbox"
|
||||
{...props}
|
||||
/>
|
||||
<div className={styles.checkbox} />
|
||||
{label}
|
||||
</label>
|
||||
{this.renderError()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
getValue() {
|
||||
const { current: el } = this.elRef;
|
||||
|
||||
return el && el.checked ? 1 : 0;
|
||||
}
|
||||
|
||||
focus() {
|
||||
const { current: el } = this.elRef;
|
||||
|
||||
el && el.focus();
|
||||
}
|
||||
}
|
||||
154
packages/app/components/ui/form/Dropdown.js
Normal file
154
packages/app/components/ui/form/Dropdown.js
Normal file
@@ -0,0 +1,154 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { omit } from 'app/functions';
|
||||
import { colors, COLOR_GREEN } from 'app/components/ui';
|
||||
|
||||
import styles from './dropdown.scss';
|
||||
import FormInputComponent from './FormInputComponent';
|
||||
|
||||
export default class Dropdown extends FormInputComponent {
|
||||
static displayName = 'Dropdown';
|
||||
|
||||
static propTypes = {
|
||||
label: PropTypes.oneOfType([
|
||||
PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
}),
|
||||
PropTypes.string,
|
||||
]).isRequired,
|
||||
items: PropTypes.arrayOf(
|
||||
PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
}),
|
||||
]),
|
||||
).isRequired,
|
||||
block: PropTypes.bool,
|
||||
color: PropTypes.oneOf(colors),
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
color: COLOR_GREEN,
|
||||
};
|
||||
|
||||
state = {
|
||||
isActive: false,
|
||||
activeItem: null,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
// listen to capturing phase to ensure, that our event handler will be
|
||||
// called before all other
|
||||
document.addEventListener('click', this.onBodyClick, true);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('click', this.onBodyClick);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { color, block, items } = this.props;
|
||||
const { isActive } = this.state;
|
||||
|
||||
const activeItem = this.getActiveItem();
|
||||
const label = this.formatMessage(activeItem.label);
|
||||
const props = omit(this.props, Object.keys(Dropdown.propTypes));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={classNames(styles[color], {
|
||||
[styles.block]: block,
|
||||
[styles.opened]: isActive,
|
||||
})}
|
||||
{...props}
|
||||
onClick={this.onToggle}
|
||||
>
|
||||
<span className={styles.label}>{label}</span>
|
||||
<span className={styles.toggleIcon} />
|
||||
|
||||
<div className={styles.menu}>
|
||||
{Object.entries(items).map(([value, label]) => (
|
||||
<div
|
||||
className={styles.menuItem}
|
||||
key={value}
|
||||
onClick={this.onSelectItem({ value, label })}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{this.renderError()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.setState({
|
||||
isActive: !this.state.isActive,
|
||||
});
|
||||
}
|
||||
|
||||
onSelectItem(item) {
|
||||
return event => {
|
||||
event.preventDefault();
|
||||
|
||||
this.setState({
|
||||
activeItem: item,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
getActiveItem() {
|
||||
const { items } = this.props;
|
||||
let { activeItem } = /** @type {any} */ (this.state);
|
||||
|
||||
if (!activeItem) {
|
||||
activeItem = {
|
||||
label: this.props.label,
|
||||
value: '',
|
||||
};
|
||||
|
||||
if (!activeItem.label) {
|
||||
const [[value, label]] = Object.entries(items);
|
||||
|
||||
activeItem = {
|
||||
label,
|
||||
value,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return activeItem;
|
||||
}
|
||||
|
||||
getValue() {
|
||||
return this.getActiveItem().value;
|
||||
}
|
||||
|
||||
onToggle = event => {
|
||||
event.preventDefault();
|
||||
|
||||
this.toggle();
|
||||
};
|
||||
|
||||
onBodyClick = event => {
|
||||
if (this.state.isActive) {
|
||||
const el = ReactDOM.findDOMNode(this);
|
||||
|
||||
if (!el.contains(event.target) && el !== event.taget) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.toggle();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
167
packages/app/components/ui/form/Form.tsx
Normal file
167
packages/app/components/ui/form/Form.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import logger from 'app/services/logger';
|
||||
|
||||
import FormModel from './FormModel';
|
||||
import styles from './form.scss';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
isLoading: boolean;
|
||||
form?: FormModel;
|
||||
onSubmit: (form: FormModel | FormData) => void | Promise<void>;
|
||||
onInvalid: (errors: { [errorKey: string]: string }) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
interface State {
|
||||
isTouched: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
type InputElement = HTMLInputElement | HTMLTextAreaElement;
|
||||
|
||||
export default class Form extends React.Component<Props, State> {
|
||||
static defaultProps = {
|
||||
id: 'default',
|
||||
isLoading: false,
|
||||
onSubmit() {},
|
||||
onInvalid() {},
|
||||
};
|
||||
|
||||
state = {
|
||||
isTouched: false,
|
||||
isLoading: this.props.isLoading || false,
|
||||
};
|
||||
|
||||
formEl: HTMLFormElement | null;
|
||||
|
||||
mounted = false;
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.form) {
|
||||
this.props.form.addLoadingListener(this.onLoading);
|
||||
}
|
||||
|
||||
this.mounted = true;
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps: Props) {
|
||||
if (nextProps.id !== this.props.id) {
|
||||
this.setState({
|
||||
isTouched: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
typeof nextProps.isLoading !== 'undefined' &&
|
||||
nextProps.isLoading !== this.state.isLoading
|
||||
) {
|
||||
this.setState({
|
||||
isLoading: nextProps.isLoading,
|
||||
});
|
||||
}
|
||||
|
||||
const nextForm = nextProps.form;
|
||||
|
||||
if (nextForm && this.props.form && nextForm !== this.props.form) {
|
||||
this.props.form.removeLoadingListener(this.onLoading);
|
||||
nextForm.addLoadingListener(this.onLoading);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.props.form) {
|
||||
this.props.form.removeLoadingListener(this.onLoading);
|
||||
}
|
||||
|
||||
this.mounted = false;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isLoading } = this.state;
|
||||
|
||||
return (
|
||||
<form
|
||||
className={classNames(styles.form, {
|
||||
[styles.isFormLoading]: isLoading,
|
||||
[styles.formTouched]: this.state.isTouched,
|
||||
})}
|
||||
onSubmit={this.onFormSubmit}
|
||||
ref={(el: HTMLFormElement | null) => (this.formEl = el)}
|
||||
noValidate
|
||||
>
|
||||
{this.props.children}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
submit() {
|
||||
if (!this.state.isTouched) {
|
||||
this.setState({
|
||||
isTouched: true,
|
||||
});
|
||||
}
|
||||
|
||||
const form = this.formEl;
|
||||
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.checkValidity()) {
|
||||
const result = this.props.onSubmit(
|
||||
this.props.form ? this.props.form : new FormData(form),
|
||||
);
|
||||
|
||||
if (result && result.then) {
|
||||
this.setState({ isLoading: true });
|
||||
|
||||
result
|
||||
.catch((errors: { [key: string]: string }) => {
|
||||
this.setErrors(errors);
|
||||
})
|
||||
.finally(() => this.mounted && this.setState({ isLoading: false }));
|
||||
}
|
||||
} else {
|
||||
const invalidEls: NodeListOf<InputElement> = form.querySelectorAll(
|
||||
':invalid',
|
||||
);
|
||||
const errors = {};
|
||||
invalidEls[0].focus(); // focus on first error
|
||||
|
||||
Array.from(invalidEls).reduce((acc, el: InputElement) => {
|
||||
if (!el.name) {
|
||||
logger.warn('Found an element without name', { el });
|
||||
|
||||
return acc;
|
||||
}
|
||||
|
||||
let errorMessage = el.validationMessage;
|
||||
|
||||
if (el.validity.valueMissing) {
|
||||
errorMessage = `error.${el.name}_required`;
|
||||
} else if (el.validity.typeMismatch) {
|
||||
errorMessage = `error.${el.name}_invalid`;
|
||||
}
|
||||
|
||||
acc[el.name] = errorMessage;
|
||||
|
||||
return acc;
|
||||
}, errors);
|
||||
|
||||
this.setErrors(errors);
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(errors: { [key: string]: string }) {
|
||||
this.props.form && this.props.form.setErrors(errors);
|
||||
this.props.onInvalid(errors);
|
||||
}
|
||||
|
||||
onFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
this.submit();
|
||||
};
|
||||
|
||||
onLoading = (isLoading: boolean) => this.setState({ isLoading });
|
||||
}
|
||||
41
packages/app/components/ui/form/FormComponent.tsx
Normal file
41
packages/app/components/ui/form/FormComponent.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { MessageDescriptor } from 'react-intl';
|
||||
import i18n from 'app/services/i18n';
|
||||
|
||||
class FormComponent<P, S = {}> extends React.Component<P, S> {
|
||||
/**
|
||||
* Formats message resolving intl translations
|
||||
*
|
||||
* @param {string|object} message - message string, or intl message descriptor with an `id` field
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
formatMessage(message: string | MessageDescriptor): string {
|
||||
if (!message) {
|
||||
throw new Error('A message is required');
|
||||
}
|
||||
|
||||
if (typeof message === 'string') {
|
||||
return message;
|
||||
}
|
||||
|
||||
if (!message.id) {
|
||||
throw new Error(`Invalid message format: ${JSON.stringify(message)}`);
|
||||
}
|
||||
|
||||
return i18n.getIntl().formatMessage(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Focuses this field
|
||||
*/
|
||||
focus() {}
|
||||
|
||||
/**
|
||||
* A hook, that called, when the form was submitted with invalid data
|
||||
* This is useful for the cases, when some field needs to be refreshed e.g. captcha
|
||||
*/
|
||||
onFormInvalid() {}
|
||||
}
|
||||
|
||||
export default FormComponent;
|
||||
31
packages/app/components/ui/form/FormError.tsx
Normal file
31
packages/app/components/ui/form/FormError.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import errorsDict from 'app/services/errorsDict';
|
||||
|
||||
import styles from './form.scss';
|
||||
|
||||
export default function FormError({
|
||||
error,
|
||||
}: {
|
||||
error?:
|
||||
| string
|
||||
| React.ReactNode
|
||||
| {
|
||||
type: string;
|
||||
payload: { [key: string]: any };
|
||||
};
|
||||
}) {
|
||||
return error ? (
|
||||
<div className={styles.fieldError}>{errorsDict.resolve(error)}</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
FormError.propTypes = {
|
||||
error: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.shape({
|
||||
type: PropTypes.string.isRequired,
|
||||
payload: PropTypes.object.isRequired,
|
||||
}),
|
||||
]),
|
||||
};
|
||||
35
packages/app/components/ui/form/FormInputComponent.tsx
Normal file
35
packages/app/components/ui/form/FormInputComponent.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { MessageDescriptor } from 'react-intl';
|
||||
|
||||
import FormComponent from './FormComponent';
|
||||
import FormError from './FormError';
|
||||
|
||||
type Error = string | MessageDescriptor;
|
||||
|
||||
export default class FormInputComponent<P, S = {}> extends FormComponent<
|
||||
P & {
|
||||
error?: Error;
|
||||
},
|
||||
S & {
|
||||
error?: Error;
|
||||
}
|
||||
> {
|
||||
componentWillReceiveProps() {
|
||||
if (this.state && this.state.error) {
|
||||
Reflect.deleteProperty(this.state, 'error');
|
||||
|
||||
this.setState(this.state);
|
||||
}
|
||||
}
|
||||
|
||||
renderError() {
|
||||
const error = (this.state && this.state.error) || this.props.error;
|
||||
|
||||
return <FormError error={error} />;
|
||||
}
|
||||
|
||||
setError(error: Error) {
|
||||
// @ts-ignore
|
||||
this.setState({ error });
|
||||
}
|
||||
}
|
||||
213
packages/app/components/ui/form/FormModel.ts
Normal file
213
packages/app/components/ui/form/FormModel.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import FormInputComponent from './FormInputComponent';
|
||||
|
||||
type LoadingListener = (isLoading: boolean) => void;
|
||||
|
||||
type ValidationError =
|
||||
| string
|
||||
| {
|
||||
type: string;
|
||||
payload?: { [key: string]: any };
|
||||
};
|
||||
|
||||
export default class FormModel {
|
||||
fields = {};
|
||||
errors: {
|
||||
[fieldId: string]: ValidationError;
|
||||
} = {};
|
||||
handlers: LoadingListener[] = [];
|
||||
renderErrors: boolean;
|
||||
_isLoading: boolean;
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {bool} [options.renderErrors=true] - whether the bound filed should
|
||||
* render their errors
|
||||
*/
|
||||
constructor(options: { renderErrors?: boolean } = {}) {
|
||||
this.renderErrors = options.renderErrors !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects form with React's component
|
||||
*
|
||||
* Usage:
|
||||
* <input {...this.form.bindField('foo')} type="text" />
|
||||
*
|
||||
* @param {string} name - the name of field
|
||||
*
|
||||
* @returns {object} - ref and name props for component
|
||||
*/
|
||||
bindField(name: string) {
|
||||
this.fields[name] = {};
|
||||
|
||||
const props: { [key: string]: any } = {
|
||||
name,
|
||||
ref: (el: FormInputComponent<any> | null) => {
|
||||
if (el) {
|
||||
if (!(el instanceof FormInputComponent)) {
|
||||
throw new Error('Expected FormInputComponent component');
|
||||
}
|
||||
|
||||
this.fields[name] = el;
|
||||
} else {
|
||||
delete this.fields[name];
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if (this.renderErrors && this.getError(name)) {
|
||||
props.error = this.getError(name);
|
||||
}
|
||||
|
||||
return props;
|
||||
}
|
||||
|
||||
/**
|
||||
* Focuses field
|
||||
*
|
||||
* @param {string} fieldId - an id of field to focus
|
||||
*/
|
||||
focus(fieldId: string) {
|
||||
if (!this.fields[fieldId]) {
|
||||
throw new Error(
|
||||
`Can not focus. The field with an id ${fieldId} does not exists`,
|
||||
);
|
||||
}
|
||||
|
||||
this.fields[fieldId].focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value of field
|
||||
*
|
||||
* @param {string} fieldId - an id of field to get value of
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
value(fieldId: string) {
|
||||
const field = this.fields[fieldId];
|
||||
|
||||
if (!field) {
|
||||
throw new Error(
|
||||
`Can not get value. The field with an id ${fieldId} does not exists`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!field.getValue) {
|
||||
return ''; // the field was not initialized through ref yet
|
||||
}
|
||||
|
||||
return field.getValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add errors to form fields
|
||||
*
|
||||
* errorType may be string or object {type: string, payload: object}, where
|
||||
* payload is additional data for errorType
|
||||
*
|
||||
* @param {object} errors - object maping {fieldId: errorType}
|
||||
*/
|
||||
setErrors(errors: { [key: string]: ValidationError }) {
|
||||
if (typeof errors !== 'object' || errors === null) {
|
||||
throw new Error('Errors must be an object');
|
||||
}
|
||||
|
||||
const oldErrors = this.errors;
|
||||
this.errors = errors;
|
||||
|
||||
Object.keys(this.fields).forEach(fieldId => {
|
||||
if (this.renderErrors) {
|
||||
if (oldErrors[fieldId] || errors[fieldId]) {
|
||||
this.fields[fieldId].setError(errors[fieldId] || null);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.hasErrors()) {
|
||||
this.fields[fieldId].onFormInvalid();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getFirstError(): ValidationError | null {
|
||||
const [error] = Object.values(this.errors);
|
||||
|
||||
return error || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error by id
|
||||
*
|
||||
* @param {string} fieldId - an id of field to get error for
|
||||
*
|
||||
* @returns {string|object|null}
|
||||
*/
|
||||
getError(fieldId: string) {
|
||||
return this.errors[fieldId] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {bool}
|
||||
*/
|
||||
hasErrors() {
|
||||
return Object.keys(this.errors).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert form into key-value object representation
|
||||
*
|
||||
* @returns {object}
|
||||
*/
|
||||
serialize(): { [key: string]: any } {
|
||||
return Object.keys(this.fields).reduce((acc, fieldId) => {
|
||||
const field = this.fields[fieldId];
|
||||
|
||||
if (field) {
|
||||
acc[fieldId] = field.getValue();
|
||||
} else {
|
||||
console.warn('Can not serialize %s field. Because it is null', fieldId);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind handler to listen for form loading state change
|
||||
*
|
||||
* @param {Function} fn
|
||||
*/
|
||||
addLoadingListener(fn: LoadingListener) {
|
||||
this.removeLoadingListener(fn);
|
||||
this.handlers.push(fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove form loading state handler
|
||||
*
|
||||
* @param {Function} fn
|
||||
*/
|
||||
removeLoadingListener(fn: LoadingListener) {
|
||||
this.handlers = this.handlers.filter(handler => handler !== fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch form in loading state
|
||||
*/
|
||||
beginLoading() {
|
||||
this._isLoading = true;
|
||||
this.notifyHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable loading state
|
||||
*/
|
||||
endLoading() {
|
||||
this._isLoading = false;
|
||||
this.notifyHandlers();
|
||||
}
|
||||
|
||||
private notifyHandlers() {
|
||||
this.handlers.forEach(fn => fn(this._isLoading));
|
||||
}
|
||||
}
|
||||
31
packages/app/components/ui/form/Input.test.tsx
Normal file
31
packages/app/components/ui/form/Input.test.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import expect from 'app/test/unexpected';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
|
||||
import Input from './Input';
|
||||
|
||||
describe('Input', () => {
|
||||
it('should return input value', () => {
|
||||
let component: any;
|
||||
|
||||
const wrapper = mount(
|
||||
<IntlProvider locale="en" defaultLocale="en">
|
||||
<Input
|
||||
defaultValue="foo"
|
||||
name="test"
|
||||
ref={el => {
|
||||
component = el;
|
||||
}}
|
||||
/>
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper.find('input[name="test"]').getDOMNode().value,
|
||||
'to equal',
|
||||
'foo',
|
||||
);
|
||||
expect(component.getValue(), 'to equal', 'foo');
|
||||
});
|
||||
});
|
||||
178
packages/app/components/ui/form/Input.tsx
Normal file
178
packages/app/components/ui/form/Input.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import React from 'react';
|
||||
import { MessageDescriptor } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
import { uniqueId, omit } from 'app/functions';
|
||||
import copy from 'app/services/copy';
|
||||
import icons from 'app/components/ui/icons.scss';
|
||||
import { SKIN_DARK, COLOR_GREEN, Skin, Color } from 'app/components/ui';
|
||||
|
||||
import styles from './form.scss';
|
||||
import FormInputComponent from './FormInputComponent';
|
||||
|
||||
let copiedStateTimeout: NodeJS.Timeout;
|
||||
|
||||
export default class Input extends FormInputComponent<
|
||||
Omit<React.InputHTMLAttributes<HTMLInputElement>, 'placeholder'> & {
|
||||
skin: Skin;
|
||||
color: Color;
|
||||
center: boolean;
|
||||
disabled: boolean;
|
||||
label?: string | MessageDescriptor;
|
||||
placeholder?: string | MessageDescriptor;
|
||||
error?: string | { type: string; payload: string };
|
||||
icon?: string;
|
||||
copy?: boolean;
|
||||
},
|
||||
{
|
||||
wasCopied: boolean;
|
||||
}
|
||||
> {
|
||||
static defaultProps = {
|
||||
color: COLOR_GREEN,
|
||||
skin: SKIN_DARK,
|
||||
center: false,
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
state = {
|
||||
wasCopied: false,
|
||||
};
|
||||
|
||||
elRef = React.createRef<HTMLInputElement>();
|
||||
|
||||
render() {
|
||||
const {
|
||||
color,
|
||||
skin,
|
||||
center,
|
||||
icon: iconType,
|
||||
copy: showCopyIcon,
|
||||
placeholder: placeholderText,
|
||||
} = this.props;
|
||||
let { label: labelContent } = this.props;
|
||||
const { wasCopied } = this.state;
|
||||
let placeholder: string | undefined;
|
||||
|
||||
const props = omit(
|
||||
{
|
||||
type: 'text',
|
||||
...this.props,
|
||||
},
|
||||
[
|
||||
'label',
|
||||
'placeholder',
|
||||
'error',
|
||||
'skin',
|
||||
'color',
|
||||
'center',
|
||||
'icon',
|
||||
'copy',
|
||||
],
|
||||
);
|
||||
|
||||
let label: React.ReactElement | null = null;
|
||||
let copyIcon: React.ReactElement | null = null;
|
||||
let icon: React.ReactElement | null = null;
|
||||
|
||||
if (labelContent) {
|
||||
if (!props.id) {
|
||||
props.id = uniqueId('input');
|
||||
}
|
||||
|
||||
labelContent = this.formatMessage(labelContent);
|
||||
|
||||
label = (
|
||||
<label className={styles.textFieldLabel} htmlFor={props.id}>
|
||||
{labelContent}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
if (placeholderText) {
|
||||
placeholder = this.formatMessage(placeholderText);
|
||||
}
|
||||
|
||||
let baseClass = styles.formRow;
|
||||
|
||||
if (iconType) {
|
||||
baseClass = styles.formIconRow;
|
||||
icon = (
|
||||
<span className={classNames(styles.textFieldIcon, icons[iconType])} />
|
||||
);
|
||||
}
|
||||
|
||||
if (showCopyIcon) {
|
||||
copyIcon = (
|
||||
<div
|
||||
className={classNames(styles.copyIcon, {
|
||||
[icons.clipboard]: !wasCopied,
|
||||
[icons.checkmark]: wasCopied,
|
||||
[styles.copyCheckmark]: wasCopied,
|
||||
})}
|
||||
onClick={this.onCopy}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
{label}
|
||||
<div className={styles.textFieldContainer}>
|
||||
<input
|
||||
ref={this.elRef}
|
||||
className={classNames(
|
||||
styles[`${skin}TextField`],
|
||||
styles[`${color}TextField`],
|
||||
{
|
||||
[styles.textFieldCenter]: center,
|
||||
},
|
||||
)}
|
||||
placeholder={placeholder}
|
||||
{...props}
|
||||
/>
|
||||
{icon}
|
||||
{copyIcon}
|
||||
</div>
|
||||
{this.renderError()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
getValue() {
|
||||
const { current: el } = this.elRef;
|
||||
|
||||
return el && el.value;
|
||||
}
|
||||
|
||||
focus() {
|
||||
const { current: el } = this.elRef;
|
||||
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
el.focus();
|
||||
setTimeout(el.focus.bind(el), 10);
|
||||
}
|
||||
|
||||
onCopy = async () => {
|
||||
const value = this.getValue();
|
||||
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
clearTimeout(copiedStateTimeout);
|
||||
copiedStateTimeout = setTimeout(
|
||||
() => this.setState({ wasCopied: false }),
|
||||
2000,
|
||||
);
|
||||
|
||||
await copy(value);
|
||||
this.setState({ wasCopied: true });
|
||||
} catch (err) {
|
||||
// it's okay
|
||||
}
|
||||
};
|
||||
}
|
||||
18
packages/app/components/ui/form/LinkButton.tsx
Normal file
18
packages/app/components/ui/form/LinkButton.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import Button from './Button';
|
||||
|
||||
export default function LinkButton(
|
||||
props: React.ComponentProps<typeof Button> &
|
||||
React.ComponentProps<typeof Link>,
|
||||
) {
|
||||
const { to, ...restProps } = props;
|
||||
|
||||
return (
|
||||
<Button
|
||||
component={linkProps => <Link {...linkProps} to={to} />}
|
||||
{...restProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
66
packages/app/components/ui/form/Radio.tsx
Normal file
66
packages/app/components/ui/form/Radio.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { MessageDescriptor } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
import { SKIN_DARK, COLOR_GREEN } from 'app/components/ui';
|
||||
import { omit } from 'app/functions';
|
||||
import { Color, Skin } from 'app/components/ui';
|
||||
|
||||
import styles from './form.scss';
|
||||
import FormInputComponent from './FormInputComponent';
|
||||
|
||||
export default class Radio extends FormInputComponent<
|
||||
{
|
||||
color: Color;
|
||||
skin: Skin;
|
||||
label: string | MessageDescriptor;
|
||||
} & React.InputHTMLAttributes<HTMLInputElement>
|
||||
> {
|
||||
static defaultProps = {
|
||||
color: COLOR_GREEN,
|
||||
skin: SKIN_DARK,
|
||||
};
|
||||
|
||||
elRef = React.createRef<HTMLInputElement>();
|
||||
|
||||
render() {
|
||||
const { color, skin } = this.props;
|
||||
let { label } = this.props;
|
||||
|
||||
label = this.formatMessage(label);
|
||||
|
||||
const props = omit(this.props, ['color', 'skin', 'label']);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
styles[`${color}MarkableRow`],
|
||||
styles[`${skin}MarkableRow`],
|
||||
)}
|
||||
>
|
||||
<label className={styles.markableContainer}>
|
||||
<input
|
||||
ref={this.elRef}
|
||||
className={styles.markableInput}
|
||||
type="radio"
|
||||
{...props}
|
||||
/>
|
||||
<div className={styles.radio} />
|
||||
{label}
|
||||
</label>
|
||||
{this.renderError()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
getValue() {
|
||||
const { current: el } = this.elRef;
|
||||
|
||||
return el && el.checked ? 1 : 0;
|
||||
}
|
||||
|
||||
focus() {
|
||||
const { current: el } = this.elRef;
|
||||
|
||||
el && el.focus();
|
||||
}
|
||||
}
|
||||
106
packages/app/components/ui/form/TextArea.tsx
Normal file
106
packages/app/components/ui/form/TextArea.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import React from 'react';
|
||||
import { MessageDescriptor } from 'react-intl';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import classNames from 'classnames';
|
||||
import { uniqueId, omit } from 'app/functions';
|
||||
import { SKIN_DARK, COLOR_GREEN, Skin, Color } from 'app/components/ui';
|
||||
|
||||
import styles from './form.scss';
|
||||
import FormInputComponent from './FormInputComponent';
|
||||
|
||||
type TextareaAutosizeProps = {
|
||||
onHeightChange?: (number, TextareaAutosizeProps) => void;
|
||||
useCacheForDOMMeasurements?: boolean;
|
||||
minRows?: number;
|
||||
maxRows?: number;
|
||||
inputRef?: (el?: HTMLTextAreaElement) => void;
|
||||
};
|
||||
|
||||
export default class TextArea extends FormInputComponent<
|
||||
{
|
||||
placeholder?: string | MessageDescriptor;
|
||||
label?: string | MessageDescriptor;
|
||||
error?: string;
|
||||
skin: Skin;
|
||||
color: Color;
|
||||
} & TextareaAutosizeProps &
|
||||
React.TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||
> {
|
||||
static defaultProps = {
|
||||
color: COLOR_GREEN,
|
||||
skin: SKIN_DARK,
|
||||
};
|
||||
|
||||
elRef = React.createRef<HTMLTextAreaElement>();
|
||||
|
||||
render() {
|
||||
const {
|
||||
color,
|
||||
skin,
|
||||
label: labelText,
|
||||
placeholder: placeholderText,
|
||||
} = this.props;
|
||||
let label: React.ReactElement | undefined;
|
||||
let placeholder: string | undefined;
|
||||
|
||||
const props = omit(
|
||||
{
|
||||
type: 'text',
|
||||
...this.props,
|
||||
},
|
||||
['label', 'placeholder', 'error', 'skin', 'color'],
|
||||
);
|
||||
|
||||
if (labelText) {
|
||||
if (!props.id) {
|
||||
props.id = uniqueId('textarea');
|
||||
}
|
||||
|
||||
label = (
|
||||
<label className={styles.textFieldLabel} htmlFor={props.id}>
|
||||
{this.formatMessage(labelText)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
if (placeholderText) {
|
||||
placeholder = this.formatMessage(placeholderText);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.formRow}>
|
||||
{label}
|
||||
<div className={styles.textAreaContainer}>
|
||||
<TextareaAutosize
|
||||
inputRef={this.elRef}
|
||||
className={classNames(
|
||||
styles.textArea,
|
||||
styles[`${skin}TextField`],
|
||||
styles[`${color}TextField`],
|
||||
)}
|
||||
placeholder={placeholder}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
{this.renderError()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
getValue() {
|
||||
const { current: el } = this.elRef;
|
||||
|
||||
return el && el.value;
|
||||
}
|
||||
|
||||
focus() {
|
||||
const { current: el } = this.elRef;
|
||||
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
el.focus();
|
||||
setTimeout(el.focus.bind(el), 10);
|
||||
}
|
||||
}
|
||||
129
packages/app/components/ui/form/dropdown.scss
Normal file
129
packages/app/components/ui/form/dropdown.scss
Normal file
@@ -0,0 +1,129 @@
|
||||
@import '~app/components/ui/colors.scss';
|
||||
@import '~app/components/ui/fonts.scss';
|
||||
|
||||
$dropdownPadding: 15px;
|
||||
|
||||
@mixin dropdown-theme($themeName, $backgroundColor) {
|
||||
.#{$themeName} {
|
||||
composes: dropdown;
|
||||
|
||||
background-color: $backgroundColor;
|
||||
|
||||
.menuItem:hover,
|
||||
&:hover {
|
||||
background-color: lighter($backgroundColor);
|
||||
}
|
||||
|
||||
&:active,
|
||||
&.opened {
|
||||
background-color: darker($backgroundColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
height: 50px;
|
||||
// 28px - ширина иконки при заданном размере шрифта
|
||||
padding: 0 ($dropdownPadding * 2 + 28px) 0 $dropdownPadding;
|
||||
position: relative;
|
||||
|
||||
font-family: $font-family-title;
|
||||
color: $defaultButtonTextColor;
|
||||
font-size: 18px;
|
||||
line-height: 50px;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
|
||||
transition: background-color 0.25s;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.opened {
|
||||
}
|
||||
|
||||
.toggleIcon {
|
||||
composes: selecter from '~app/components/ui/icons.scss';
|
||||
|
||||
position: absolute;
|
||||
right: $dropdownPadding;
|
||||
top: 16px;
|
||||
font-size: 17px;
|
||||
transition: right 0.3s cubic-bezier(0.23, 1, 0.32, 1); // easeOutQuint
|
||||
|
||||
.dropdown:hover & {
|
||||
right: $dropdownPadding - 5px;
|
||||
}
|
||||
|
||||
.dropdown:active &,
|
||||
.dropdown.opened & {
|
||||
right: $dropdownPadding + 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
.menu {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
|
||||
width: 120%;
|
||||
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
|
||||
transition: 0.5s ease;
|
||||
transition-property: opacity, visibility;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
|
||||
.dropdown.opened & {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
composes: label;
|
||||
|
||||
height: 50px;
|
||||
padding: 0 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
color: #444;
|
||||
line-height: 50px;
|
||||
font-size: 18px;
|
||||
font-family: $font-family-title;
|
||||
|
||||
border-bottom: 1px solid #ebe8df;
|
||||
cursor: pointer;
|
||||
|
||||
transition: 0.25s;
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
@include dropdown-theme('green', $green);
|
||||
430
packages/app/components/ui/form/form.scss
Normal file
430
packages/app/components/ui/form/form.scss
Normal file
@@ -0,0 +1,430 @@
|
||||
@import '~app/components/ui/colors.scss';
|
||||
@import '~app/components/ui/fonts.scss';
|
||||
|
||||
@mixin form-transition() {
|
||||
// Анимация фона должна быть быстрее анимации рамки, т.к. визуально фон заполняется медленнее
|
||||
transition: border-color 0.25s, background-color 0.2s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input
|
||||
*/
|
||||
@mixin input-theme($themeName, $color) {
|
||||
.#{$themeName}TextField {
|
||||
composes: textField;
|
||||
|
||||
&:focus {
|
||||
border-color: $color;
|
||||
|
||||
~ .textFieldIcon {
|
||||
background: $color;
|
||||
border-color: $color;
|
||||
}
|
||||
|
||||
&.lightTextField {
|
||||
color: $color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.formRow {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.formIconRow {
|
||||
composes: formRow;
|
||||
|
||||
.textField {
|
||||
padding-left: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
.textFieldContainer {
|
||||
position: relative;
|
||||
height: 50px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.textField {
|
||||
box-sizing: border-box;
|
||||
height: 50px;
|
||||
width: 100%;
|
||||
|
||||
border: 2px solid;
|
||||
|
||||
font-size: 18px;
|
||||
color: #aaa;
|
||||
font-family: $font-family-title;
|
||||
padding: 0 10px;
|
||||
|
||||
transition: border-color 0.25s;
|
||||
|
||||
&:hover {
|
||||
&,
|
||||
~ .textFieldIcon {
|
||||
border-color: #aaa;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
color: #fff;
|
||||
outline: none;
|
||||
|
||||
~ .textFieldIcon {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.textFieldIcon {
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 50px;
|
||||
width: 50px;
|
||||
line-height: 46px;
|
||||
text-align: center;
|
||||
border: 2px solid;
|
||||
color: #444;
|
||||
cursor: default;
|
||||
|
||||
@include form-transition();
|
||||
}
|
||||
|
||||
.copyIcon {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 10px;
|
||||
|
||||
padding: 5px;
|
||||
cursor: pointer;
|
||||
|
||||
font-size: 20px;
|
||||
|
||||
transition: 0.25s;
|
||||
}
|
||||
|
||||
.copyCheckmark {
|
||||
color: $green !important;
|
||||
}
|
||||
|
||||
.darkTextField {
|
||||
background: $black;
|
||||
|
||||
&::placeholder {
|
||||
opacity: 1;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
&,
|
||||
~ .textFieldIcon {
|
||||
border-color: lighter($black);
|
||||
}
|
||||
|
||||
~ .copyIcon {
|
||||
color: #999;
|
||||
background: $black;
|
||||
|
||||
&:hover {
|
||||
background: lighter($black);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightTextField {
|
||||
background: #fff;
|
||||
|
||||
&:disabled {
|
||||
background: #dcd8cd;
|
||||
|
||||
~ .copyIcon {
|
||||
background: #dcd8ce;
|
||||
|
||||
&:hover {
|
||||
background: #ebe8e2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
opacity: 1;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
&,
|
||||
~ .textFieldIcon {
|
||||
border-color: #dcd8cd;
|
||||
}
|
||||
|
||||
~ .copyIcon {
|
||||
color: #aaa;
|
||||
background: #fff;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.textFieldLabel {
|
||||
margin: 10px 0;
|
||||
display: block;
|
||||
|
||||
font-family: $font-family-title;
|
||||
color: #666;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.fieldError {
|
||||
color: $red;
|
||||
font-size: 12px;
|
||||
margin: 3px 0;
|
||||
|
||||
a {
|
||||
border-bottom-color: rgba($red, 0.75);
|
||||
color: $red;
|
||||
|
||||
&:hover {
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.textAreaContainer {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.textArea {
|
||||
height: auto; // unset .textField height
|
||||
min-height: 50px;
|
||||
padding: 5px 10px;
|
||||
resize: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.textFieldCenter {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@include input-theme('green', $green);
|
||||
@include input-theme('blue', $blue);
|
||||
@include input-theme('red', $red);
|
||||
@include input-theme('darkBlue', $dark_blue);
|
||||
@include input-theme('lightViolet', $light_violet);
|
||||
@include input-theme('violet', $violet);
|
||||
|
||||
/**
|
||||
* Markable is our common name for checkboxes and radio buttons
|
||||
*/
|
||||
@mixin markable-theme($themeName, $color) {
|
||||
.#{$themeName}MarkableRow {
|
||||
composes: markableRow;
|
||||
|
||||
.markableContainer {
|
||||
&:hover {
|
||||
.mark {
|
||||
border-color: $color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.markableInput {
|
||||
&:checked {
|
||||
+ .mark {
|
||||
background: $color;
|
||||
border-color: $color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.markableRow {
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.markableContainer {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
padding-left: 27px;
|
||||
|
||||
font-family: $font-family-title;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.markPosition {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
left: 0;
|
||||
top: 0;
|
||||
margin: 0;
|
||||
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.markableInput {
|
||||
composes: markPosition;
|
||||
opacity: 0;
|
||||
|
||||
&:checked {
|
||||
+ .mark {
|
||||
&:before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mark {
|
||||
composes: markPosition;
|
||||
composes: checkmark from '~app/components/ui/icons.scss';
|
||||
|
||||
border: 2px #dcd8cd solid;
|
||||
|
||||
font-size: 10px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
|
||||
@include form-transition();
|
||||
|
||||
&:before {
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
composes: mark;
|
||||
}
|
||||
|
||||
.radio {
|
||||
composes: mark;
|
||||
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.lightMarkableRow {
|
||||
.markableContainer {
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.darkMarkableRow {
|
||||
.markableContainer {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
@include markable-theme('green', $green);
|
||||
@include markable-theme('blue', $blue);
|
||||
@include markable-theme('red', $red);
|
||||
|
||||
.isFormLoading {
|
||||
// TODO: надо бы разнести from и input на отдельные модули,
|
||||
// так как в текущем контексте isLoading немного не логичен,
|
||||
// пришлось юзать isFormLoading
|
||||
* {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[type='submit'] {
|
||||
// TODO: duplicate of .loading from components/ui/buttons
|
||||
background: url('./images/loader_button.gif') #95a5a6 center center !important;
|
||||
|
||||
cursor: default;
|
||||
color: #fff;
|
||||
transition: 0.25s;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.captchaContainer {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.captcha {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 302px;
|
||||
height: 77px;
|
||||
overflow: hidden;
|
||||
|
||||
border: 2px solid;
|
||||
transition: border-color 0.25s;
|
||||
|
||||
> div {
|
||||
margin: -2px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #aaa;
|
||||
}
|
||||
|
||||
// minimum captcha width is 302px, which can not be changed
|
||||
// using transform to scale down to 296px
|
||||
// transform-origin: 0;
|
||||
// transform: scaleX(0.98);
|
||||
}
|
||||
|
||||
.darkCaptcha {
|
||||
border-color: lighter($black);
|
||||
}
|
||||
|
||||
.lightCaptcha {
|
||||
border-color: #dcd8cd;
|
||||
}
|
||||
|
||||
.captchaLoader {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/**
|
||||
* Form validation
|
||||
*/
|
||||
// Disable any visual error indication
|
||||
// .formTouched .textField:invalid {
|
||||
// box-shadow: none;
|
||||
|
||||
// &,
|
||||
// ~ .textFieldIcon {
|
||||
// border-color: #3e2727;
|
||||
// }
|
||||
|
||||
// ~ .textFieldIcon {
|
||||
// color: #3e2727;
|
||||
// }
|
||||
|
||||
// &:hover {
|
||||
// &,
|
||||
// ~ .textFieldIcon {
|
||||
// border-color: $red;
|
||||
// }
|
||||
// }
|
||||
|
||||
// &:focus {
|
||||
// border-color: $red;
|
||||
|
||||
// ~ .textFieldIcon {
|
||||
// background: $red;
|
||||
// border-color: $red;
|
||||
// color: #fff;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// .formTouched .checkboxInput:invalid {
|
||||
// ~ .checkbox {
|
||||
// border-color: $red;
|
||||
// }
|
||||
// }
|
||||
BIN
packages/app/components/ui/form/images/loader_button.gif
Normal file
BIN
packages/app/components/ui/form/images/loader_button.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 317 B |
25
packages/app/components/ui/form/index.ts
Normal file
25
packages/app/components/ui/form/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import Input from './Input';
|
||||
import TextArea from './TextArea';
|
||||
import Checkbox from './Checkbox';
|
||||
import Radio from './Radio';
|
||||
import Button from './Button';
|
||||
import LinkButton from './LinkButton';
|
||||
import Form from './Form';
|
||||
import FormModel from './FormModel';
|
||||
import Dropdown from './Dropdown';
|
||||
import Captcha from './Captcha';
|
||||
import FormError from './FormError';
|
||||
|
||||
export {
|
||||
Input,
|
||||
TextArea,
|
||||
Button,
|
||||
LinkButton,
|
||||
Checkbox,
|
||||
Radio,
|
||||
Form,
|
||||
FormModel,
|
||||
Dropdown,
|
||||
Captcha,
|
||||
FormError,
|
||||
};
|
||||
24
packages/app/components/ui/icons.scss
Normal file
24
packages/app/components/ui/icons.scss
Normal file
@@ -0,0 +1,24 @@
|
||||
@import '~app/icons.font.json';
|
||||
|
||||
// По умолчанию стрелка смотрит вниз, это просто алиас
|
||||
.arrowBottom {
|
||||
@extend .arrow;
|
||||
}
|
||||
|
||||
.arrowTop {
|
||||
@extend .arrow;
|
||||
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.arrowRight {
|
||||
@extend .arrow;
|
||||
|
||||
transform: rotate(270deg);
|
||||
}
|
||||
|
||||
.arrowLeft {
|
||||
@extend .arrow;
|
||||
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
41
packages/app/components/ui/index.ts
Normal file
41
packages/app/components/ui/index.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export type Color =
|
||||
| 'green'
|
||||
| 'blue'
|
||||
| 'darkBlue'
|
||||
| 'violet'
|
||||
| 'lightViolet'
|
||||
| 'orange'
|
||||
| 'red'
|
||||
| 'black'
|
||||
| 'white';
|
||||
|
||||
export type Skin = 'dark' | 'light';
|
||||
|
||||
export const COLOR_GREEN: Color = 'green';
|
||||
export const COLOR_BLUE: Color = 'blue';
|
||||
export const COLOR_DARK_BLUE: Color = 'darkBlue';
|
||||
export const COLOR_VIOLET: Color = 'violet';
|
||||
export const COLOR_LIGHT_VIOLET: Color = 'lightViolet';
|
||||
export const COLOR_ORANGE: Color = 'orange';
|
||||
export const COLOR_RED: Color = 'red';
|
||||
export const COLOR_BLACK: Color = 'black';
|
||||
export const COLOR_WHITE: Color = 'white';
|
||||
|
||||
export const colors: Array<Color> = [
|
||||
COLOR_GREEN,
|
||||
COLOR_BLUE,
|
||||
COLOR_DARK_BLUE,
|
||||
COLOR_VIOLET,
|
||||
COLOR_LIGHT_VIOLET,
|
||||
COLOR_ORANGE,
|
||||
COLOR_RED,
|
||||
COLOR_BLACK,
|
||||
COLOR_WHITE,
|
||||
];
|
||||
|
||||
export const SKIN_DARK: Skin = 'dark';
|
||||
export const SKIN_LIGHT: Skin = 'light';
|
||||
|
||||
export const skins: Array<Skin> = [SKIN_DARK, SKIN_LIGHT];
|
||||
|
||||
export { default as RelativeTime } from './RelativeTime';
|
||||
27
packages/app/components/ui/loader/ComponentLoader.tsx
Normal file
27
packages/app/components/ui/loader/ComponentLoader.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { Skin } from 'app/components/ui';
|
||||
|
||||
import styles from './componentLoader.scss';
|
||||
|
||||
function ComponentLoader({ skin = 'dark' }: { skin?: Skin }) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.componentLoader,
|
||||
styles[`${skin}ComponentLoader`],
|
||||
)}
|
||||
>
|
||||
<div className={styles.spins}>
|
||||
{new Array(5).fill(0).map((_, index) => (
|
||||
<div
|
||||
className={classNames(styles.spin, styles[`spin${index}`])}
|
||||
key={index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ComponentLoader;
|
||||
71
packages/app/components/ui/loader/ImageLoader.tsx
Normal file
71
packages/app/components/ui/loader/ImageLoader.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { ComponentLoader } from 'app/components/ui/loader';
|
||||
import { SKIN_LIGHT } from 'app/components/ui';
|
||||
|
||||
import styles from './imageLoader.scss';
|
||||
|
||||
export default class ImageLoader extends React.Component<
|
||||
{
|
||||
src: string;
|
||||
alt: string;
|
||||
ratio: number; // width:height ratio
|
||||
onLoad?: Function;
|
||||
},
|
||||
{
|
||||
isLoading: boolean;
|
||||
}
|
||||
> {
|
||||
state = {
|
||||
isLoading: true,
|
||||
};
|
||||
|
||||
componentWillMount() {
|
||||
this.preloadImage();
|
||||
}
|
||||
|
||||
preloadImage() {
|
||||
const img = new Image();
|
||||
img.onload = () => this.imageLoaded();
|
||||
img.onerror = () => this.preloadImage();
|
||||
img.src = this.props.src;
|
||||
}
|
||||
|
||||
imageLoaded() {
|
||||
this.setState({ isLoading: false });
|
||||
|
||||
if (this.props.onLoad) {
|
||||
this.props.onLoad();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isLoading } = this.state;
|
||||
const { src, alt, ratio } = this.props;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div
|
||||
style={{
|
||||
height: 0,
|
||||
paddingBottom: `${ratio * 100}%`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{isLoading && (
|
||||
<div className={styles.loader}>
|
||||
<ComponentLoader skin={SKIN_LIGHT} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={classNames(styles.image, {
|
||||
[styles.imageLoaded]: !isLoading,
|
||||
})}
|
||||
>
|
||||
<img src={src} alt={alt} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
69
packages/app/components/ui/loader/componentLoader.scss
Normal file
69
packages/app/components/ui/loader/componentLoader.scss
Normal file
@@ -0,0 +1,69 @@
|
||||
@import '~app/components/ui/colors.scss';
|
||||
|
||||
.componentLoader {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.spins {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.spin {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
display: inline-block;
|
||||
margin: 10px 2px;
|
||||
opacity: 0;
|
||||
animation: loaderAnimation 1s infinite;
|
||||
}
|
||||
|
||||
.spin1 {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.spin2 {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
|
||||
.spin3 {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.spin4 {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
.spin5 {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skins
|
||||
*/
|
||||
.lightComponentLoader {
|
||||
.spin {
|
||||
background: #aaa;
|
||||
}
|
||||
}
|
||||
|
||||
.darkComponentLoader {
|
||||
.spin {
|
||||
background: #444;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loaderAnimation {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scaleY(2);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
25
packages/app/components/ui/loader/imageLoader.scss
Normal file
25
packages/app/components/ui/loader/imageLoader.scss
Normal file
@@ -0,0 +1,25 @@
|
||||
.container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.loader {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.image {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
opacity: 0;
|
||||
transition: 0.4s ease-in;
|
||||
}
|
||||
|
||||
.imageLoaded {
|
||||
opacity: 1;
|
||||
}
|
||||
2
packages/app/components/ui/loader/index.ts
Normal file
2
packages/app/components/ui/loader/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as ImageLoader } from './ImageLoader';
|
||||
export { default as ComponentLoader } from './ComponentLoader';
|
||||
12
packages/app/components/ui/loader/loader.html
Normal file
12
packages/app/components/ui/loader/loader.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<div id="loader" class="loader-overlay is-first-launch">
|
||||
<div class="loader">
|
||||
<div class="loader__cube loader__cube--1"></div>
|
||||
<div class="loader__cube loader__cube--2"></div>
|
||||
<div class="loader__cube loader__cube--3"></div>
|
||||
<div class="loader__cube loader__cube--4"></div>
|
||||
<div class="loader__cube loader__cube--5"></div>
|
||||
<div class="loader__cube loader__cube--6"></div>
|
||||
<div class="loader__cube loader__cube--7"></div>
|
||||
<div class="loader__cube loader__cube--8"></div>
|
||||
</div>
|
||||
</div>
|
||||
192
packages/app/components/ui/loader/loader.scss
Normal file
192
packages/app/components/ui/loader/loader.scss
Normal file
@@ -0,0 +1,192 @@
|
||||
.loader-overlay {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 100;
|
||||
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
|
||||
transition: 0.4s ease;
|
||||
|
||||
&.is-first-launch {
|
||||
// if loader is shown for the first time, we will
|
||||
// remove opacity and hide the entire site contents
|
||||
background: #f2efeb;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.loader {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin-top: -75px;
|
||||
margin-left: -75px;
|
||||
width: 153px;
|
||||
height: 153px;
|
||||
|
||||
&__cube {
|
||||
position: absolute;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background: rgba(35, 35, 35, 0.6);
|
||||
display: inline-block;
|
||||
animation-iteration-count: infinite;
|
||||
animation-duration: 0.5s;
|
||||
animation-timing-function: linear;
|
||||
|
||||
&--1 {
|
||||
margin: 0 0 0 0;
|
||||
animation-name: cube1;
|
||||
}
|
||||
|
||||
&--2 {
|
||||
margin: 0 0 0 051px;
|
||||
animation-name: cube2;
|
||||
}
|
||||
|
||||
&--3 {
|
||||
margin: 0 0 0 102px;
|
||||
animation-name: cube3;
|
||||
}
|
||||
|
||||
&--4 {
|
||||
margin: 51px 0 0 0;
|
||||
animation-name: cube4;
|
||||
}
|
||||
|
||||
&--5 {
|
||||
margin: 051px 0 0 051px;
|
||||
}
|
||||
|
||||
&--6 {
|
||||
margin: 51px 0 0 102px;
|
||||
animation-name: cube6;
|
||||
}
|
||||
|
||||
&--7 {
|
||||
margin: 102px 0 0 0;
|
||||
animation-name: cube7;
|
||||
}
|
||||
|
||||
&--8 {
|
||||
margin: 102px 0 0 051px;
|
||||
animation-name: cube8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cube1 {
|
||||
25% {
|
||||
margin: 0 0 0 0;
|
||||
}
|
||||
50% {
|
||||
margin: 0 0 0 51px;
|
||||
}
|
||||
75% {
|
||||
margin: 0 0 0 51px;
|
||||
}
|
||||
100% {
|
||||
margin: 0 0 0 51px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cube2 {
|
||||
25% {
|
||||
margin: 0 0 0 51px;
|
||||
}
|
||||
50% {
|
||||
margin: 0 0 0 102px;
|
||||
}
|
||||
75% {
|
||||
margin: 0 0 0 102px;
|
||||
}
|
||||
100% {
|
||||
margin: 0 0 0 102px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cube3 {
|
||||
25% {
|
||||
margin: 51px 0 0 102px;
|
||||
}
|
||||
50% {
|
||||
margin: 51px 0 0 102px;
|
||||
}
|
||||
75% {
|
||||
margin: 51px 0 0 102px;
|
||||
}
|
||||
100% {
|
||||
margin: 51px 0 0 102px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cube4 {
|
||||
25% {
|
||||
margin: 51px 0 0 0;
|
||||
}
|
||||
50% {
|
||||
margin: 51px 0 0 0;
|
||||
}
|
||||
75% {
|
||||
margin: 0 0 0 0;
|
||||
}
|
||||
100% {
|
||||
margin: 0 0 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cube6 {
|
||||
25% {
|
||||
margin: 102px 0 0 102px;
|
||||
}
|
||||
50% {
|
||||
margin: 102px 0 0 102px;
|
||||
}
|
||||
75% {
|
||||
margin: 102px 0 0 102px;
|
||||
}
|
||||
100% {
|
||||
margin: 102px 0 0 51px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cube7 {
|
||||
25% {
|
||||
margin: 102px 0 0 0;
|
||||
}
|
||||
50% {
|
||||
margin: 102px 0 0 0;
|
||||
}
|
||||
75% {
|
||||
margin: 51px 0 0 0;
|
||||
}
|
||||
100% {
|
||||
margin: 51px 0 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cube8 {
|
||||
25% {
|
||||
margin: 102px 0 0 51px;
|
||||
}
|
||||
50% {
|
||||
margin: 102px 0 0 51px;
|
||||
}
|
||||
75% {
|
||||
margin: 102px 0 0 51px;
|
||||
}
|
||||
100% {
|
||||
margin: 102px 0 0 0;
|
||||
}
|
||||
}
|
||||
99
packages/app/components/ui/motion/SlideMotion.tsx
Normal file
99
packages/app/components/ui/motion/SlideMotion.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React from 'react';
|
||||
import { Motion, spring } from 'react-motion';
|
||||
import MeasureHeight from 'app/components/MeasureHeight';
|
||||
|
||||
import styles from './slide-motion.scss';
|
||||
|
||||
interface State {
|
||||
[stepHeight: string]: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
class SlideMotion extends React.Component<
|
||||
{
|
||||
activeStep: number;
|
||||
children: React.ReactNode;
|
||||
},
|
||||
State
|
||||
> {
|
||||
state: State = {
|
||||
version: 0,
|
||||
};
|
||||
|
||||
isHeightMeasured: boolean;
|
||||
|
||||
componentWillReceiveProps() {
|
||||
// mark this view as dirty to re-measure height
|
||||
this.setState({
|
||||
version: this.state.version + 1,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { activeStep, children } = this.props;
|
||||
|
||||
const { version } = this.state;
|
||||
|
||||
const activeStepHeight = this.state[`step${activeStep}Height`] || 0;
|
||||
|
||||
// a hack to disable height animation on first render
|
||||
const { isHeightMeasured } = this;
|
||||
this.isHeightMeasured = isHeightMeasured || activeStepHeight > 0;
|
||||
|
||||
const motionStyle = {
|
||||
transform: spring(activeStep * 100, {
|
||||
stiffness: 500,
|
||||
damping: 50,
|
||||
precision: 0.5,
|
||||
}),
|
||||
height: isHeightMeasured
|
||||
? spring(activeStepHeight, {
|
||||
stiffness: 500,
|
||||
damping: 20,
|
||||
precision: 0.5,
|
||||
})
|
||||
: activeStepHeight,
|
||||
};
|
||||
|
||||
return (
|
||||
<Motion style={motionStyle}>
|
||||
{(interpolatingStyle: { height: number; transform: string }) => (
|
||||
<div
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
height: `${interpolatingStyle.height}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={styles.container}
|
||||
style={{
|
||||
WebkitTransform: `translateX(-${interpolatingStyle.transform}%)`,
|
||||
transform: `translateX(-${interpolatingStyle.transform}%)`,
|
||||
}}
|
||||
>
|
||||
{React.Children.map(children, (child, index) => (
|
||||
<MeasureHeight
|
||||
className={styles.item}
|
||||
onMeasure={this.onStepMeasure(index)}
|
||||
state={version}
|
||||
key={index}
|
||||
>
|
||||
{child}
|
||||
</MeasureHeight>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
);
|
||||
}
|
||||
|
||||
onStepMeasure(step: number) {
|
||||
return (height: number) =>
|
||||
this.setState({
|
||||
[`step${step}Height`]: height,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default SlideMotion;
|
||||
1
packages/app/components/ui/motion/index.ts
Normal file
1
packages/app/components/ui/motion/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as SlideMotion } from './SlideMotion';
|
||||
10
packages/app/components/ui/motion/slide-motion.scss
Normal file
10
packages/app/components/ui/motion/slide-motion.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
.container {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: inline-block;
|
||||
white-space: normal;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
}
|
||||
138
packages/app/components/ui/panel.scss
Normal file
138
packages/app/components/ui/panel.scss
Normal file
@@ -0,0 +1,138 @@
|
||||
@import '~app/components/ui/colors.scss';
|
||||
@import '~app/components/ui/fonts.scss';
|
||||
|
||||
.panel {
|
||||
background: $black;
|
||||
}
|
||||
|
||||
$headerHeight: 60px;
|
||||
|
||||
.header {
|
||||
box-sizing: border-box;
|
||||
height: $headerHeight;
|
||||
border-bottom: 1px solid lighter($black);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
font-family: $font-family-title;
|
||||
text-align: center;
|
||||
// Шрифт Roboto Condensed имеет странную высоту линии, так что компенсируем это
|
||||
line-height: $headerHeight + 2px;
|
||||
font-size: 20px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.headerControl {
|
||||
composes: black from '~app/components/ui/buttons.scss';
|
||||
|
||||
float: left;
|
||||
overflow: hidden;
|
||||
height: $headerHeight - 1px;
|
||||
width: 49px;
|
||||
padding: 0;
|
||||
border-right: 1px solid lighter($black);
|
||||
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
$bodyLeftRightPadding: 20px;
|
||||
$bodyTopBottomPadding: 15px;
|
||||
|
||||
.body {
|
||||
overflow: hidden;
|
||||
padding: $bodyTopBottomPadding $bodyLeftRightPadding;
|
||||
color: #ccc;
|
||||
font-size: 18px;
|
||||
|
||||
b {
|
||||
font-weight: normal;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #fff !important;
|
||||
border-bottom-color: rgba(#fff, 0.75);
|
||||
|
||||
&:hover {
|
||||
border-bottom-color: rgba(#fff, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 50px;
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.bodyHeader {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 10px 20px;
|
||||
margin: (-$bodyTopBottomPadding) (-$bodyLeftRightPadding) 15px;
|
||||
max-height: 200px;
|
||||
|
||||
transition: 0.4s ease;
|
||||
}
|
||||
|
||||
.isClosed {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.errorBodyHeader {
|
||||
composes: bodyHeader;
|
||||
|
||||
background: $red;
|
||||
border: 1px darker($red) solid;
|
||||
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
color: #fff;
|
||||
|
||||
a {
|
||||
&:hover {
|
||||
text-shadow: 0 0 1px #fff;
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.defaultBodyHeader {
|
||||
composes: bodyHeader;
|
||||
|
||||
background: darker($black);
|
||||
border: lighter($black) solid;
|
||||
border-bottom-width: 5px;
|
||||
border-top-width: 4px;
|
||||
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.close {
|
||||
composes: close from '~app/components/ui/icons.scss';
|
||||
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 5px;
|
||||
|
||||
font-size: 10px;
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.panelIcon {
|
||||
color: #ccc;
|
||||
font-size: 100px;
|
||||
line-height: 1;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
189
packages/app/components/ui/popup/PopupStack.test.js
Normal file
189
packages/app/components/ui/popup/PopupStack.test.js
Normal file
@@ -0,0 +1,189 @@
|
||||
import sinon from 'sinon';
|
||||
import expect from 'app/test/unexpected';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { shallow, mount } from 'enzyme';
|
||||
|
||||
import { PopupStack } from 'app/components/ui/popup/PopupStack';
|
||||
import styles from 'app/components/ui/popup/popup.scss';
|
||||
|
||||
function DummyPopup(/** @type {{[key: string]: any}} */ _props) {
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('<PopupStack />', () => {
|
||||
it('renders all popup components', () => {
|
||||
const props = {
|
||||
destroy: () => {},
|
||||
popups: [
|
||||
{
|
||||
Popup: DummyPopup,
|
||||
},
|
||||
{
|
||||
Popup: DummyPopup,
|
||||
},
|
||||
],
|
||||
};
|
||||
const component = shallow(<PopupStack {...props} />);
|
||||
|
||||
expect(component.find(DummyPopup), 'to satisfy', { length: 2 });
|
||||
});
|
||||
|
||||
it('should pass onClose as props', () => {
|
||||
const expectedProps = {
|
||||
foo: 'bar',
|
||||
};
|
||||
|
||||
const props = {
|
||||
destroy: () => {},
|
||||
popups: [
|
||||
{
|
||||
Popup: (props = {}) => {
|
||||
// eslint-disable-next-line
|
||||
expect(props.onClose, 'to be a', 'function');
|
||||
|
||||
return <DummyPopup {...expectedProps} />;
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const component = mount(<PopupStack {...props} />);
|
||||
|
||||
const popup = component.find(DummyPopup);
|
||||
expect(popup, 'to satisfy', { length: 1 });
|
||||
expect(popup.props(), 'to equal', expectedProps);
|
||||
});
|
||||
|
||||
it('should hide popup, when onClose called', () => {
|
||||
const props = {
|
||||
popups: [
|
||||
{
|
||||
Popup: DummyPopup,
|
||||
},
|
||||
{
|
||||
Popup: DummyPopup,
|
||||
},
|
||||
],
|
||||
destroy: sinon.stub().named('props.destroy'),
|
||||
};
|
||||
const component = shallow(<PopupStack {...props} />);
|
||||
|
||||
component
|
||||
.find(DummyPopup)
|
||||
.last()
|
||||
.prop('onClose')();
|
||||
|
||||
expect(props.destroy, 'was called once');
|
||||
expect(props.destroy, 'to have a call satisfying', [
|
||||
expect.it('to be', props.popups[1]),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should hide popup, when overlay clicked', () => {
|
||||
const preventDefault = sinon.stub().named('event.preventDefault');
|
||||
const props = {
|
||||
destroy: sinon.stub().named('props.destroy'),
|
||||
popups: [
|
||||
{
|
||||
Popup: DummyPopup,
|
||||
},
|
||||
],
|
||||
};
|
||||
const component = shallow(<PopupStack {...props} />);
|
||||
|
||||
const overlay = component.find(`.${styles.overlay}`);
|
||||
overlay.simulate('click', { target: 1, currentTarget: 1, preventDefault });
|
||||
|
||||
expect(props.destroy, 'was called once');
|
||||
expect(preventDefault, 'was called once');
|
||||
});
|
||||
|
||||
it('should hide popup on overlay click if disableOverlayClose', () => {
|
||||
const props = {
|
||||
destroy: sinon.stub().named('props.destroy'),
|
||||
popups: [
|
||||
{
|
||||
Popup: DummyPopup,
|
||||
disableOverlayClose: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
const component = shallow(<PopupStack {...props} />);
|
||||
|
||||
const overlay = component.find(`.${styles.overlay}`);
|
||||
overlay.simulate('click', {
|
||||
target: 1,
|
||||
currentTarget: 1,
|
||||
preventDefault() {},
|
||||
});
|
||||
|
||||
expect(props.destroy, 'was not called');
|
||||
});
|
||||
|
||||
it('should hide popup, when esc pressed', () => {
|
||||
const props = {
|
||||
destroy: sinon.stub().named('props.destroy'),
|
||||
popups: [
|
||||
{
|
||||
Popup: DummyPopup,
|
||||
},
|
||||
],
|
||||
};
|
||||
mount(<PopupStack {...props} />);
|
||||
|
||||
const event = new Event('keyup');
|
||||
// @ts-ignore
|
||||
event.which = 27;
|
||||
document.dispatchEvent(event);
|
||||
|
||||
expect(props.destroy, 'was called once');
|
||||
});
|
||||
|
||||
it('should hide first popup in stack if esc pressed', () => {
|
||||
const props = {
|
||||
destroy: sinon.stub().named('props.destroy'),
|
||||
popups: [
|
||||
{
|
||||
Popup() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
{
|
||||
Popup: DummyPopup,
|
||||
},
|
||||
],
|
||||
};
|
||||
mount(<PopupStack {...props} />);
|
||||
|
||||
const event = new Event('keyup');
|
||||
// @ts-ignore
|
||||
event.which = 27;
|
||||
document.dispatchEvent(event);
|
||||
|
||||
expect(props.destroy, 'was called once');
|
||||
expect(props.destroy, 'to have a call satisfying', [
|
||||
expect.it('to be', props.popups[1]),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should NOT hide popup on esc pressed if disableOverlayClose', () => {
|
||||
const props = {
|
||||
destroy: sinon.stub().named('props.destroy'),
|
||||
popups: [
|
||||
{
|
||||
Popup: DummyPopup,
|
||||
disableOverlayClose: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
mount(<PopupStack {...props} />);
|
||||
|
||||
const event = new Event('keyup');
|
||||
// @ts-ignore
|
||||
event.which = 27;
|
||||
document.dispatchEvent(event);
|
||||
|
||||
expect(props.destroy, 'was not called');
|
||||
});
|
||||
});
|
||||
104
packages/app/components/ui/popup/PopupStack.tsx
Normal file
104
packages/app/components/ui/popup/PopupStack.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
import { TransitionGroup, CSSTransition } from 'react-transition-group';
|
||||
import { browserHistory } from 'app/services/history';
|
||||
import { connect } from 'react-redux';
|
||||
import { RootState } from 'app/reducers';
|
||||
|
||||
import { PopupConfig } from './reducer';
|
||||
import { destroy } from './actions';
|
||||
import styles from './popup.scss';
|
||||
|
||||
export class PopupStack extends React.Component<{
|
||||
popups: PopupConfig[];
|
||||
destroy: (popup: PopupConfig) => void;
|
||||
}> {
|
||||
unlistenTransition: () => void;
|
||||
|
||||
componentWillMount() {
|
||||
document.addEventListener('keyup', this.onKeyPress);
|
||||
this.unlistenTransition = browserHistory.listen(this.onRouteLeave);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('keyup', this.onKeyPress);
|
||||
this.unlistenTransition();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { popups } = this.props;
|
||||
|
||||
return (
|
||||
<TransitionGroup>
|
||||
{popups.map((popup, index) => {
|
||||
const { Popup } = popup;
|
||||
|
||||
return (
|
||||
<CSSTransition
|
||||
key={index}
|
||||
classNames={{
|
||||
enter: styles.trEnter,
|
||||
enterActive: styles.trEnterActive,
|
||||
exit: styles.trExit,
|
||||
exitActive: styles.trExitActive,
|
||||
}}
|
||||
timeout={500}
|
||||
>
|
||||
<div
|
||||
className={styles.overlay}
|
||||
onClick={this.onOverlayClick(popup)}
|
||||
>
|
||||
<Popup onClose={this.onClose(popup)} />
|
||||
</div>
|
||||
</CSSTransition>
|
||||
);
|
||||
})}
|
||||
</TransitionGroup>
|
||||
);
|
||||
}
|
||||
|
||||
onClose(popup: PopupConfig) {
|
||||
return this.props.destroy.bind(null, popup);
|
||||
}
|
||||
|
||||
onOverlayClick(popup: PopupConfig) {
|
||||
return (event: React.MouseEvent) => {
|
||||
if (event.target !== event.currentTarget || popup.disableOverlayClose) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
this.props.destroy(popup);
|
||||
};
|
||||
}
|
||||
|
||||
popStack() {
|
||||
const [popup] = this.props.popups.slice(-1);
|
||||
|
||||
if (popup && !popup.disableOverlayClose) {
|
||||
this.props.destroy(popup);
|
||||
}
|
||||
}
|
||||
|
||||
onKeyPress = (event: KeyboardEvent) => {
|
||||
if (event.which === 27) {
|
||||
// ESC key
|
||||
this.popStack();
|
||||
}
|
||||
};
|
||||
|
||||
onRouteLeave = nextLocation => {
|
||||
if (nextLocation) {
|
||||
this.popStack();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(
|
||||
(state: RootState) => ({
|
||||
...state.popup,
|
||||
}),
|
||||
{
|
||||
destroy,
|
||||
},
|
||||
)(PopupStack);
|
||||
17
packages/app/components/ui/popup/actions.ts
Normal file
17
packages/app/components/ui/popup/actions.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { PopupConfig } from './reducer';
|
||||
|
||||
export const POPUP_CREATE = 'POPUP_CREATE';
|
||||
export function create(payload: PopupConfig) {
|
||||
return {
|
||||
type: POPUP_CREATE,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
export const POPUP_DESTROY = 'POPUP_DESTROY';
|
||||
export function destroy(popup: PopupConfig) {
|
||||
return {
|
||||
type: POPUP_DESTROY,
|
||||
payload: popup,
|
||||
};
|
||||
}
|
||||
174
packages/app/components/ui/popup/popup.scss
Normal file
174
packages/app/components/ui/popup/popup.scss
Normal file
@@ -0,0 +1,174 @@
|
||||
@import '~app/components/ui/colors.scss';
|
||||
@import '~app/components/ui/fonts.scss';
|
||||
|
||||
$popupPadding: 20px; // Отступ контента внутри попапа
|
||||
$popupMargin: 20px; // Отступ попапа от краёв окна
|
||||
|
||||
@mixin popupBounding($width, $padding: null) {
|
||||
@if ($padding == null) {
|
||||
$padding: $popupMargin;
|
||||
}
|
||||
|
||||
@if ($padding != $popupMargin) {
|
||||
margin: $padding auto;
|
||||
padding: 0 $padding;
|
||||
}
|
||||
|
||||
max-width: $width;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
||||
z-index: 200;
|
||||
|
||||
background: rgba(#fff, 0.8);
|
||||
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
perspective: 600px;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
height: 100%;
|
||||
width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.popupWrapper {
|
||||
box-sizing: border-box;
|
||||
margin: 0 auto;
|
||||
padding: $popupMargin;
|
||||
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
vertical-align: middle;
|
||||
|
||||
transition: max-width 0.3s;
|
||||
}
|
||||
|
||||
.popup {
|
||||
white-space: normal;
|
||||
text-align: left;
|
||||
|
||||
background: #fff;
|
||||
box-shadow: 0 0 10px rgba(#000, 0.2);
|
||||
|
||||
color: #666;
|
||||
|
||||
:invalid {
|
||||
button {
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
background: $white;
|
||||
padding: 15px $popupPadding;
|
||||
border-bottom: 1px solid #dedede;
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
font-size: 20px;
|
||||
font-family: $font-family-title;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: $popupPadding;
|
||||
}
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
padding: 15px;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
color: rgba(#000, 0.4);
|
||||
background: rgba(#000, 0);
|
||||
transition: 0.25s;
|
||||
transform: translateX(0);
|
||||
|
||||
&:hover {
|
||||
color: rgba(#000, 0.6);
|
||||
background: rgba(#fff, 0.75);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 655px) {
|
||||
.close {
|
||||
position: fixed;
|
||||
padding: 35px;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition states
|
||||
*/
|
||||
$popupInitPosition: translateY(10%) rotateX(-8deg);
|
||||
|
||||
.trEnter {
|
||||
opacity: 0;
|
||||
|
||||
.popup {
|
||||
opacity: 0;
|
||||
transform: $popupInitPosition;
|
||||
}
|
||||
|
||||
&Active {
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s ease-in;
|
||||
|
||||
.popup {
|
||||
opacity: 1;
|
||||
transform: translateY(0) rotateX(0deg);
|
||||
transition: 0.3s cubic-bezier(0.23, 1, 0.32, 1); // easeOutQuint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.trExit {
|
||||
opacity: 1;
|
||||
overflow: hidden;
|
||||
|
||||
.popup {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&Active {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in;
|
||||
|
||||
.popup {
|
||||
opacity: 0;
|
||||
transform: $popupInitPosition;
|
||||
transition: 0.3s cubic-bezier(0.165, 0.84, 0.44, 1); // easeOutQuart
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.trEnter,
|
||||
.trExit {
|
||||
.close {
|
||||
// do not show close during transition, because transform forces position: fixed
|
||||
// to layout relative container, instead of body
|
||||
opacity: 0;
|
||||
transform: translate(100%);
|
||||
transition: 0s;
|
||||
}
|
||||
}
|
||||
98
packages/app/components/ui/popup/reducer.test.js
Normal file
98
packages/app/components/ui/popup/reducer.test.js
Normal file
@@ -0,0 +1,98 @@
|
||||
import expect from 'app/test/unexpected';
|
||||
import React from 'react';
|
||||
import reducer from 'app/components/ui/popup/reducer';
|
||||
import { create, destroy } from 'app/components/ui/popup/actions';
|
||||
|
||||
describe('popup/reducer', () => {
|
||||
it('should have no popups by default', () => {
|
||||
const actual = reducer(undefined, {
|
||||
type: 'init',
|
||||
});
|
||||
|
||||
expect(actual.popups, 'to be an', 'array');
|
||||
expect(actual.popups, 'to be empty');
|
||||
});
|
||||
|
||||
describe('#create', () => {
|
||||
it('should create popup', () => {
|
||||
const actual = reducer(
|
||||
undefined,
|
||||
create({
|
||||
Popup: FakeComponent,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(actual.popups[0], 'to equal', {
|
||||
Popup: FakeComponent,
|
||||
});
|
||||
});
|
||||
|
||||
it('should create multiple popups', () => {
|
||||
let actual = reducer(
|
||||
undefined,
|
||||
create({
|
||||
Popup: FakeComponent,
|
||||
}),
|
||||
);
|
||||
actual = reducer(
|
||||
actual,
|
||||
create({
|
||||
Popup: FakeComponent,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(actual.popups[1], 'to equal', {
|
||||
Popup: FakeComponent,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when no type provided', () => {
|
||||
expect(
|
||||
() =>
|
||||
reducer(
|
||||
undefined,
|
||||
// @ts-ignore
|
||||
create({}),
|
||||
),
|
||||
'to throw',
|
||||
'Popup is required',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#destroy', () => {
|
||||
let state;
|
||||
let popup;
|
||||
|
||||
beforeEach(() => {
|
||||
state = reducer(state, create({ Popup: FakeComponent }));
|
||||
popup = state.popups[0];
|
||||
});
|
||||
|
||||
it('should remove popup', () => {
|
||||
expect(state.popups, 'to have length', 1);
|
||||
|
||||
state = reducer(state, destroy(popup));
|
||||
|
||||
expect(state.popups, 'to have length', 0);
|
||||
});
|
||||
|
||||
it('should not remove something, that it should not', () => {
|
||||
state = reducer(
|
||||
state,
|
||||
create({
|
||||
Popup: FakeComponent,
|
||||
}),
|
||||
);
|
||||
|
||||
state = reducer(state, destroy(popup));
|
||||
|
||||
expect(state.popups, 'to have length', 1);
|
||||
expect(state.popups[0], 'not to be', popup);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function FakeComponent() {
|
||||
return <span />;
|
||||
}
|
||||
36
packages/app/components/ui/popup/reducer.ts
Normal file
36
packages/app/components/ui/popup/reducer.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import { combineReducers } from 'redux';
|
||||
|
||||
import { POPUP_CREATE, POPUP_DESTROY } from './actions';
|
||||
|
||||
export interface PopupConfig {
|
||||
Popup: React.ElementType;
|
||||
props?: { [key: string]: any };
|
||||
// do not allow hiding popup
|
||||
disableOverlayClose?: boolean;
|
||||
}
|
||||
|
||||
export type State = {
|
||||
popups: PopupConfig[];
|
||||
};
|
||||
|
||||
export default combineReducers<State>({
|
||||
popups,
|
||||
});
|
||||
|
||||
function popups(state: PopupConfig[] = [], { type, payload }) {
|
||||
switch (type) {
|
||||
case POPUP_CREATE:
|
||||
if (!payload.Popup) {
|
||||
throw new Error('Popup is required');
|
||||
}
|
||||
|
||||
return state.concat(payload);
|
||||
|
||||
case POPUP_DESTROY:
|
||||
return state.filter(popup => popup !== payload);
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
36
packages/app/components/ui/scroll/ScrollIntoView.tsx
Normal file
36
packages/app/components/ui/scroll/ScrollIntoView.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { RouteComponentProps } from 'react-router-dom';
|
||||
import React from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { restoreScroll } from './scroll';
|
||||
|
||||
class ScrollIntoView extends React.PureComponent<
|
||||
RouteComponentProps & {
|
||||
top?: boolean; // do not touch any DOM and simply scroll to top on location change
|
||||
}
|
||||
> {
|
||||
componentDidMount() {
|
||||
this.onPageUpdate();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.location !== prevProps.location) {
|
||||
this.onPageUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
onPageUpdate() {
|
||||
if (this.props.top) {
|
||||
restoreScroll();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.top) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <span ref={el => el && restoreScroll(el)} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(ScrollIntoView);
|
||||
1
packages/app/components/ui/scroll/index.ts
Normal file
1
packages/app/components/ui/scroll/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ScrollIntoView } from './ScrollIntoView';
|
||||
165
packages/app/components/ui/scroll/scroll.ts
Normal file
165
packages/app/components/ui/scroll/scroll.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Implements scroll to animation with momentum effect
|
||||
*
|
||||
* @see http://ariya.ofilabs.com/2013/11/javascript-kinetic-scrolling-part-2.html
|
||||
*/
|
||||
|
||||
const TIME_CONSTANT = 100; // higher numbers - slower animation
|
||||
const SCROLL_ANCHOR_OFFSET = 80; // 50 + 30 (header height + some spacing)
|
||||
// Первый скролл выполняется сразу после загрузки страницы, так что чтобы снизить
|
||||
// нагрузку на рендеринг мы откладываем первый скрол на 200ms
|
||||
let isFirstScroll = true;
|
||||
let scrollJob: {
|
||||
hasAmplitude: boolean;
|
||||
start: number;
|
||||
y: number;
|
||||
amplitude: number;
|
||||
} | null = null;
|
||||
|
||||
export function scrollTo(y: number) {
|
||||
if (scrollJob) {
|
||||
// we already scrolling, so simply change the coordinates we are scrolling to
|
||||
if (scrollJob.hasAmplitude) {
|
||||
const delta = y - scrollJob.y;
|
||||
scrollJob.amplitude += delta;
|
||||
}
|
||||
|
||||
scrollJob.y = y;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
let scrollWasTouched = false;
|
||||
scrollJob = {
|
||||
// NOTE: we may use some sort of debounce to wait till we catch all the
|
||||
// scroll requests after app state changes, but the way with hasAmplitude
|
||||
// seems to be more reliable
|
||||
hasAmplitude: false,
|
||||
start,
|
||||
y,
|
||||
amplitude: 0,
|
||||
};
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
// wrap in requestAnimationFrame to optimize initial reading of scrollTop
|
||||
if (!scrollJob) {
|
||||
return;
|
||||
}
|
||||
|
||||
const y = normalizeScrollPosition(scrollJob.y);
|
||||
|
||||
scrollJob.hasAmplitude = true;
|
||||
scrollJob.y = y;
|
||||
scrollJob.amplitude = y - getScrollTop();
|
||||
|
||||
(function animateScroll() {
|
||||
if (!scrollJob) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { start, y, amplitude } = scrollJob;
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
let delta = -amplitude * Math.exp(-elapsed / TIME_CONSTANT);
|
||||
|
||||
if (Math.abs(delta) > 0.5 && !scrollWasTouched) {
|
||||
requestAnimationFrame(animateScroll);
|
||||
} else {
|
||||
// the last animation frame
|
||||
delta = 0;
|
||||
scrollJob = null;
|
||||
document.removeEventListener('mousewheel', markScrollTouched);
|
||||
document.removeEventListener('touchstart', markScrollTouched);
|
||||
}
|
||||
|
||||
if (scrollWasTouched) {
|
||||
// block any animation visualisation in case, when user touched scroll
|
||||
return;
|
||||
}
|
||||
|
||||
const newScrollTop = y + delta;
|
||||
window.scrollTo(0, newScrollTop);
|
||||
})();
|
||||
});
|
||||
|
||||
document.addEventListener('mousewheel', markScrollTouched);
|
||||
document.addEventListener('touchstart', markScrollTouched);
|
||||
function markScrollTouched() {
|
||||
scrollWasTouched = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures, that `y` is the coordinate, that can be physically scrolled to
|
||||
*
|
||||
* @param {number} y
|
||||
*
|
||||
* @returns {number}
|
||||
*/
|
||||
function normalizeScrollPosition(y: number): number {
|
||||
const contentHeight =
|
||||
(document.documentElement && document.documentElement.scrollHeight) || 0;
|
||||
const windowHeight: number = window.innerHeight;
|
||||
const maxY = contentHeight - windowHeight;
|
||||
|
||||
return Math.min(y, maxY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls to page's top or #anchor link, if any
|
||||
*
|
||||
* @param {?HTMLElement} targetEl - the element to scroll to
|
||||
*/
|
||||
export function restoreScroll(targetEl: HTMLElement | null = null) {
|
||||
const { hash } = window.location;
|
||||
|
||||
setTimeout(
|
||||
() => {
|
||||
isFirstScroll = false;
|
||||
|
||||
if (targetEl === null) {
|
||||
const id = hash.substr(1);
|
||||
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
targetEl = document.getElementById(id);
|
||||
}
|
||||
|
||||
const viewPort = document.body;
|
||||
|
||||
if (!viewPort) {
|
||||
console.log('Can not find viewPort element'); // eslint-disable-line
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let y = 0;
|
||||
|
||||
if (targetEl) {
|
||||
const { top } = targetEl.getBoundingClientRect();
|
||||
y = getScrollTop() + top - SCROLL_ANCHOR_OFFSET;
|
||||
}
|
||||
|
||||
scrollTo(y);
|
||||
},
|
||||
isFirstScroll ? 200 : 0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* http://stackoverflow.com/a/3464890/5184751
|
||||
*
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getScrollTop(): number {
|
||||
const doc = document.documentElement;
|
||||
|
||||
if (doc) {
|
||||
return (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
28
packages/app/components/ui/stepper/Stepper.tsx
Normal file
28
packages/app/components/ui/stepper/Stepper.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { Color, COLOR_GREEN } from 'app/components/ui';
|
||||
|
||||
import styles from './stepper.scss';
|
||||
|
||||
export default function Stepper({
|
||||
totalSteps,
|
||||
activeStep,
|
||||
color = COLOR_GREEN,
|
||||
}: {
|
||||
totalSteps: number;
|
||||
activeStep: number;
|
||||
color?: Color;
|
||||
}) {
|
||||
return (
|
||||
<div className={classNames(styles.steps, styles[`${color}Steps`])}>
|
||||
{new Array(totalSteps).fill(0).map((_, step) => (
|
||||
<div
|
||||
className={classNames(styles.step, {
|
||||
[styles.activeStep]: step <= activeStep,
|
||||
})}
|
||||
key={step}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
packages/app/components/ui/stepper/index.ts
Normal file
1
packages/app/components/ui/stepper/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Stepper';
|
||||
82
packages/app/components/ui/stepper/stepper.scss
Normal file
82
packages/app/components/ui/stepper/stepper.scss
Normal file
@@ -0,0 +1,82 @@
|
||||
@import '~app/components/ui/colors.scss';
|
||||
|
||||
.steps {
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.step {
|
||||
position: relative;
|
||||
text-align: right;
|
||||
width: 100%;
|
||||
|
||||
height: 4px;
|
||||
background: #d8d5ce;
|
||||
|
||||
&:first-child {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
display: block;
|
||||
|
||||
position: absolute;
|
||||
height: 4px;
|
||||
left: 0;
|
||||
right: 100%;
|
||||
top: 50%;
|
||||
margin-top: -2px;
|
||||
|
||||
background: #aaa;
|
||||
transition: 0.4s ease 0.1s;
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: -7px;
|
||||
z-index: 1;
|
||||
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
background: #aaa;
|
||||
border: 2px solid #aaa;
|
||||
transition: background 0.4s ease,
|
||||
border-color 0.4s cubic-bezier(0.19, 1, 0.22, 1); // easeOutExpo
|
||||
}
|
||||
}
|
||||
|
||||
.activeStep {
|
||||
&:before {
|
||||
right: 0;
|
||||
transition-delay: unset;
|
||||
}
|
||||
|
||||
&:after {
|
||||
transition-delay: 0.3s;
|
||||
}
|
||||
}
|
||||
|
||||
.greenSteps {
|
||||
.activeStep {
|
||||
&:after {
|
||||
background: $green;
|
||||
border-color: darker($green);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.violetSteps {
|
||||
.activeStep {
|
||||
&:after {
|
||||
background: $violet;
|
||||
border-color: darker($violet);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user