Lens restructure (#540)

Signed-off-by: Roman <ixrock@gmail.com>
This commit is contained in:
Roman authored and GitHub committed 2020-06-30 14:35:16 +03:00
1 parent e810eab39f
commit b7974827d2
786 files changed
+14567 -24066

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
export * from './input'
export * from './search-input'
+109
View File
@@ -0,0 +1,109 @@
.Input {
position: relative;
text-align: left;
&.invalid.dirty:not(.validating) {
label:after {
width: 100%;
background: $colorError;
}
}
&.focused:not(.validating) {
label:after {
width: 100%;
}
}
&.validatingLine {
label:after {
width: 100%;
@include stripeLinesAnimation;
}
}
label {
--flex-gap: #{$padding / 1.5};
position: relative;
padding: $padding /4 * 3 0;
border-bottom: 1px solid $halfGray;
&:after {
content: "";
position: absolute;
top: calc(100% - 1px);
left: 50%;
transform: translateX(-50%);
width: 0;
height: 2px;
background: $primary;
transition: width 250ms;
}
}
input, textarea {
background: none;
color: inherit;
font: inherit;
text-align: inherit;
text-transform: inherit;
&:invalid {
box-shadow: none;
}
&::placeholder {
color: inherit;
opacity: .75;
}
}
textarea {
@include hidden-scrollbar;
line-height: 1.2;
resize: none;
}
fieldset:disabled &,
&.disabled {
opacity: .5;
pointer-events: none;
}
.input-info {
.errors {
color: var(color-error);
font-size: $font-size-small;
}
.maxLengthIndicator {
text-align: right;
font-size: 80%;
padding: $padding / 3;
}
}
//- Themes
&.theme {
&.round-black {
label {
background: $mainBackground;
border: 1px solid $borderFaintColor;
border-radius: $radius;
padding: $padding;
&:focus-within {
border: 2px solid $colorInfo;
padding: $padding - 1;
}
&:after {
display: none;
}
}
}
}
}
+298
View File
@@ -0,0 +1,298 @@
import "./input.scss";
import React, { DOMAttributes, InputHTMLAttributes, TextareaHTMLAttributes } from "react";
import { autobind, cssNames, debouncePromise } from "../../utils";
import { Icon } from "../icon";
import { conditionalValidators, Validator } from "./input.validators";
import isString from "lodash/isString"
import isFunction from "lodash/isFunction"
import isBoolean from "lodash/isBoolean"
import uniqueId from "lodash/uniqueId"
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>
type InputElement = HTMLInputElement | HTMLTextAreaElement;
type InputElementProps = InputHTMLAttributes<InputElement> & TextareaHTMLAttributes<InputElement> & DOMAttributes<InputElement>;
export type InputProps<T = string> = Omit<InputElementProps, "onChange"> & {
theme?: "round-black";
className?: string;
value?: T;
multiLine?: boolean; // use text-area as input field
maxRows?: number; // when multiLine={true} define max rows size
dirty?: boolean; // show validation errors even if the field wasn't touched yet
showValidationLine?: boolean; // show animated validation line for async validators
iconLeft?: string | React.ReactNode; // material-icon name in case of string-type
iconRight?: string | React.ReactNode;
validators?: Validator | Validator[];
onChange?(value: T, evt: React.ChangeEvent<InputElement>): void;
}
interface State {
focused?: boolean;
dirty?: boolean;
dirtyOnBlur?: boolean;
valid?: boolean;
validating?: boolean;
errors?: React.ReactNode[];
}
const defaultProps: Partial<InputProps> = {
rows: 1,
maxRows: 10000,
showValidationLine: true,
validators: [],
}
export class Input extends React.Component<InputProps, State> {
static defaultProps = defaultProps as object;
public input: InputElement;
public validators: Validator[] = [];
public state: State = {
dirty: !!this.props.dirty,
valid: true,
errors: [],
}
setValue(value: string) {
if (value !== this.getValue()) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(this.input.constructor.prototype, "value").set;
nativeInputValueSetter.call(this.input, value);
const evt = new Event("input", { bubbles: true });
this.input.dispatchEvent(evt);
}
}
getValue(): string {
const { value, defaultValue = "" } = this.props;
if (value !== undefined) return value; // controlled input
if (this.input) return this.input.value; // uncontrolled input
return defaultValue as string;
}
focus() {
this.input.focus();
}
blur() {
this.input.blur();
}
select() {
this.input.select()
}
private autoFitHeight() {
const { multiLine, rows, maxRows } = this.props;
if (!multiLine) {
return;
}
const textArea = this.input;
const lineHeight = parseFloat(window.getComputedStyle(textArea).lineHeight);
const rowsCount = (this.getValue().match(/\n/g) || []).length + 1;
const height = lineHeight * Math.min(Math.max(rowsCount, rows), maxRows);
textArea.style.height = height + "px";
}
private validationId: string;
async validate(value = this.getValue()) {
let validationId = (this.validationId = ""); // reset every time for async validators
const asyncValidators: Promise<any>[] = [];
let errors: React.ReactNode[] = [];
// run validators
for (const validator of this.validators) {
if (errors.length) {
// stop validation check if there is an error already
break;
}
const result = validator.validate(value, this.props);
if (isBoolean(result) && !result) {
errors.push(this.getValidatorError(value, validator));
}
else if (result instanceof Promise) {
if (!validationId) {
this.validationId = validationId = uniqueId("validation_id_");
}
asyncValidators.push(
result.then(
() => null, // don't consider any valid result from promise since we interested in errors only
error => this.getValidatorError(value, validator) || error
)
);
}
}
// save sync validators result first
this.setValidation(errors);
// handle async validators result
if (asyncValidators.length > 0) {
this.setState({ validating: true, valid: false, });
const asyncErrors = await Promise.all(asyncValidators);
const isLastValidationCheck = this.validationId === validationId;
if (isLastValidationCheck) {
errors = this.state.errors.concat(asyncErrors.filter(err => err));
this.setValidation(errors);
}
}
this.input.setCustomValidity(errors.length ? errors[0].toString() : "");
}
setValidation(errors: React.ReactNode[]) {
this.setState({
validating: false,
valid: !errors.length,
errors: errors,
});
}
private getValidatorError(value: string, { message }: Validator) {
if (isFunction(message)) return message(value, this.props)
return message || "";
}
private setupValidators() {
this.validators = conditionalValidators
// add conditional validators if matches input props
.filter(validator => validator.condition(this.props))
// add custom validators
.concat(this.props.validators)
// debounce async validators
.map(({ debounce, ...validator }) => {
if (debounce) validator.validate = debouncePromise(validator.validate, debounce);
return validator;
});
// run validation
this.validate();
}
setDirty(dirty = true) {
if (this.state.dirty === dirty) return;
this.setState({ dirty });
}
@autobind()
onFocus(evt: React.FocusEvent<InputElement>) {
const { onFocus } = this.props;
if (onFocus) onFocus(evt);
this.setState({ focused: true });
}
@autobind()
onBlur(evt: React.FocusEvent<InputElement>) {
const { onBlur } = this.props;
if (onBlur) onBlur(evt);
if (this.state.dirtyOnBlur) this.setState({ dirty: true, dirtyOnBlur: false });
this.setState({ focused: false });
}
@autobind()
onChange(evt: React.ChangeEvent<any>) {
if (this.props.onChange) {
this.props.onChange(evt.currentTarget.value, evt);
}
this.validate();
this.autoFitHeight();
// mark input as dirty for the first time only onBlur() to avoid immediate error-state show when start typing
if (!this.state.dirty) this.setState({ dirtyOnBlur: true });
// re-render component when used as uncontrolled input
// when used @defaultValue instead of @value changing real input.value doesn't call render()
if (this.isUncontrolled && this.showMaxLenIndicator) {
this.forceUpdate();
}
}
get showMaxLenIndicator() {
const { maxLength, multiLine } = this.props;
return maxLength && multiLine;
}
get isUncontrolled() {
return this.props.value === undefined;
}
componentDidMount() {
this.setupValidators();
this.autoFitHeight();
}
componentDidUpdate(prevProps: InputProps) {
const { defaultValue, value, dirty, validators } = this.props;
if (prevProps.value !== value || defaultValue !== prevProps.defaultValue) {
this.validate();
this.autoFitHeight();
}
if (prevProps.dirty !== dirty) {
this.setDirty(dirty);
}
if (prevProps.validators !== validators) {
this.setupValidators();
}
}
@autobind()
bindRef(elem: InputElement) {
this.input = elem;
}
render() {
/* eslint-disable */
let { multiLine, showValidationLine, validators, theme, maxRows, children, iconLeft, iconRight, ...inputProps } = this.props;
let { className, maxLength, rows, disabled, } = this.props;
/* eslint-enable */
const { focused, dirty, valid, validating, errors } = this.state;
className = cssNames("Input", className, {
[`theme ${theme}`]: theme,
focused: focused,
disabled: disabled,
invalid: !valid,
dirty: dirty,
validating: validating,
validatingLine: validating && showValidationLine,
});
// normalize icons
if (isString(iconLeft)) iconLeft = <Icon material={iconLeft}/>
if (isString(iconRight)) iconRight = <Icon material={iconRight}/>
// prepare input props
Object.assign(inputProps, {
className: "input box grow",
onFocus: this.onFocus,
onBlur: this.onBlur,
onChange: this.onChange,
rows: multiLine ? (rows || 1) : null,
ref: this.bindRef,
});
return (
<div className={className}>
<label className="input-area flex gaps align-center">
{iconLeft}
{multiLine ? <textarea {...inputProps as any}/> : <input {...inputProps as any}/>}
{iconRight}
</label>
<div className="input-info flex gaps">
{!valid && dirty && (
<div className="errors box grow">
{errors.map((error, i) => <p key={i}>{error}</p>)}
</div>
)}
{this.showMaxLenIndicator && (
<div className="maxLengthIndicator box right">
{this.getValue().length} / {maxLength}
</div>
)}
</div>
</div>
);
}
}
@@ -0,0 +1,68 @@
import type { InputProps } from "./input";
import { ReactNode } from "react";
import { t } from "@lingui/macro";
import { _i18n } from '../../i18n';
export interface Validator {
debounce?: number; // debounce for async validators in ms
condition?(props: InputProps): boolean; // auto-bind condition depending on input props
message?: ReactNode | ((value: string, props?: InputProps) => ReactNode | string);
validate(value: string, props?: InputProps): boolean | Promise<any>; // promise can throw error message
}
export const isRequired: Validator = {
condition: ({ required }) => required,
message: () => _i18n._(t`This field is required`),
validate: value => !!value.trim(),
};
export const isEmail: Validator = {
condition: ({ type }) => type === "email",
message: () => _i18n._(t`Wrong email format`),
validate: value => !!value.match(/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/),
};
export const isNumber: Validator = {
condition: ({ type }) => type === "number",
message: () => _i18n._(t`Invalid number`),
validate: (value, { min, max }) => {
const numVal = +value;
return !(
isNaN(numVal) ||
(min != null && numVal < min) ||
(max != null && numVal > max)
)
},
};
export const isUrl: Validator = {
condition: ({ type }) => type === "url",
message: () => _i18n._(t`Wrong url format`),
validate: value => !!value.match(/^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]+$/),
};
export const minLength: Validator = {
condition: ({ minLength }) => !!minLength,
message: (value, { minLength }) => _i18n._(t`Minimum length is ${minLength}`),
validate: (value, { minLength }) => value.length >= minLength,
};
export const maxLength: Validator = {
condition: ({ maxLength }) => !!maxLength,
message: (value, { maxLength }) => _i18n._(t`Maximum length is ${maxLength}`),
validate: (value, { maxLength }) => value.length <= maxLength,
};
export const systemName: Validator = {
message: () => _i18n._(t`This field must contain only lowercase latin characters, numbers and dash.`),
validate: value => !!value.match(/^[a-z0-9-]+$/),
};
export const accountId: Validator = {
message: () => _i18n._(t`Invalid account ID`),
validate: value => (isEmail.validate(value) || systemName.validate(value))
};
export const conditionalValidators = [
isRequired, isEmail, isNumber, isUrl, minLength, maxLength
];
@@ -0,0 +1,30 @@
.Input.SearchInput {
--compact-focus-width: 190px;
max-width: 900px;
min-width: 220px;
> label {
color: inherit;
border-radius: $radius;
padding: $padding / 1.33 $padding * 1.25;
}
&.compact {
min-width: 0;
.input {
transition: width 150ms;
&:placeholder-shown:not(:focus) {
transition-duration: 100ms;
width: 0;
margin: 0;
}
&:focus, &:not(:placeholder-shown) {
width: var(--compact-focus-width);
}
}
}
}
@@ -0,0 +1,81 @@
import "./search-input.scss";
import React from "react";
import debounce from "lodash/debounce"
import { autorun, observable } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { t } from "@lingui/macro";
import { Icon } from "../icon";
import { cssNames } from "../../utils";
import { Input, InputProps } from "./input";
import { getSearch, setSearch } from "../../navigation";
import { _i18n } from '../../i18n';
interface Props extends InputProps {
compact?: boolean; // show only search-icon when not focused
}
const defaultProps: Partial<Props> = {
autoFocus: true,
get placeholder() {
return _i18n._(t`Search...`)
},
}
@observer
export class SearchInput extends React.Component<Props> {
static defaultProps = defaultProps as object;
@observable inputVal: string;
@disposeOnUnmount
updateInput = autorun(() => this.inputVal = getSearch())
updateUrl = debounce((val: string) => setSearch(val), 250)
setValue = (value: string) => {
this.inputVal = value;
this.updateUrl(value);
}
clear = () => {
this.setValue("");
this.updateUrl.flush();
}
onChange = (val: string, evt: React.ChangeEvent<any>) => {
this.setValue(val);
if (this.props.onChange) {
this.props.onChange(val, evt);
}
}
onKeyDown = (evt: React.KeyboardEvent<any>) => {
if (this.props.onKeyDown) {
this.props.onKeyDown(evt);
}
// clear on escape-key
const escapeKey = evt.nativeEvent.code === "Escape";
if (escapeKey) {
this.clear();
evt.stopPropagation();
}
}
render() {
const { inputVal } = this;
const { className, compact, ...inputProps } = this.props;
const icon = inputVal
? <Icon small material="close" onClick={this.clear}/>
: <Icon small material="search"/>
return (
<Input
{...inputProps}
className={cssNames("SearchInput", className, { compact })}
value={inputVal}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
iconRight={icon}
/>
)
}
}