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:
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,
|
||||
};
|
Reference in New Issue
Block a user