mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Lens app source code (#119)
Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
1 parent
936cbd53d4
commit
1d0815abd2
797 files changed
+73714
-215
No files matched your search
@@ -0,0 +1,2 @@
|
||||
export * from './input'
|
||||
export * from './search-input'
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
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() {
|
||||
const { multiLine, showValidationLine, validators, theme, maxRows, children, ...inputProps } = this.props;
|
||||
let { className, iconLeft, iconRight } = this.props;
|
||||
const { maxLength, rows, disabled } = this.props;
|
||||
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 { ReactNode } from "react";
|
||||
import { t } from "@lingui/macro";
|
||||
import { InputProps } from "./input";
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user