Create app namespace for all absolute requires of app modules. Move all packages under packages yarn workspace

This commit is contained in:
SleepWalker
2019-12-07 21:02:00 +02:00
parent d8d2df0702
commit f9d3bb4e20
404 changed files with 758 additions and 742 deletions

View 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>
);
}
}

View 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 });
}

View 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();
}
}

View 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();
}
}
};
}

View 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 });
}

View 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;

View 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,
}),
]),
};

View 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 });
}
}

View 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));
}
}

View 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');
});
});

View 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
}
};
}

View 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}
/>
);
}

View 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();
}
}

View 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);
}
}

View 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);

View 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;
// }
// }

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 B

View 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,
};