Skip to content
This repository has been archived by the owner on Jan 23, 2025. It is now read-only.

ui-components: Input #52

Merged
merged 8 commits into from
Jun 3, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions packages/storybook-ui-components/stories/Input.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import React, { useState } from "react";
import { withKnobs, text, boolean, number } from "@storybook/addon-knobs";
import { NumberInput, TextInput } from "@cockroachlabs/ui-components";
import { Search } from "@cockroachlabs/icons";

export default {
title: "Input",
components: [NumberInput, TextInput],
decorators: [withKnobs],
};

export const Number = () => {
const [value, setValue] = useState();
return (
<NumberInput
initialValue={number("Initial value", 0)}
value={value}
disabled={boolean("disabled", false)}
onChange={setValue}
invalid={boolean("invalid", false)}
/>
);
};

export const Text = () => {
const [value, setValue] = useState();
return (
<TextInput
initialValue={text("Initial value", "some text")}
value={value}
disabled={boolean("disabled", false)}
onChange={setValue}
invalid={boolean("invalid", false)}
/>
);
};

export const SearchInput = () => {
const [value, setValue] = useState();
return (
<TextInput
value={value}
disabled={boolean("disabled", false)}
onChange={setValue}
invalid={boolean("invalid", false)}
placeholder={text("Placeholder", "Search...")}
prefixIcon={<Search />}
/>
);
};

export const NumberInputWithIcon = () => {
const [value, setValue] = useState();
return (
<NumberInput
value={value}
disabled={boolean("disabled", false)}
onChange={setValue}
invalid={boolean("invalid", false)}
placeholder={text("Placeholder", "Counter...")}
prefixIcon={<Search />}
/>
);
};
3 changes: 3 additions & 0 deletions packages/ui-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
},
"keywords": [],
"license": "MIT",
"dependencies": {
"@cockroachlabs/icons": "^0.1.1"
},
"devDependencies": {
"@babel/cli": "^7.8.3",
"@babel/core": "^7.8.3",
Expand Down
102 changes: 102 additions & 0 deletions packages/ui-components/src/Input/BaseInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import React, {
ChangeEvent,
CSSProperties,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import classNames from "classnames/bind";
import styles from "./styles.module.scss";

type OwnInputProps<T = string | number> = {
type?: T extends string ? "text" : "number";
initialValue?: T;
value?: T;
onChange?: (value: T) => void;
className?: string;
style?: CSSProperties;
autoComplete?: string;
placeholder?: string;
name?: string;
disabled?: boolean;
invalid?: boolean;
prefixIcon?: React.ReactNode;
};

type NativeInputProps<T> = Omit<
React.InputHTMLAttributes<HTMLInputElement>,
keyof OwnInputProps<T>
>;

export type InputProps<T = string | number> = NativeInputProps<T> &
OwnInputProps<T>;

const cx = classNames.bind(styles);

export const BaseInput: React.FC<InputProps> = ({
name,
type = "text",
autoComplete = "off",
className,
style,
value: outerValue = "",
initialValue = "",
placeholder,
onChange,
disabled = false,
invalid = false,
...props
}) => {
const [value, setValue] = useState<string | number>(
outerValue || initialValue,
);
const [isDirty, setDirtyState] = useState<boolean>(false);

useEffect(() => {
if (!outerValue && !isDirty) {
return;
}
setDirtyState(true);
setValue(outerValue);
}, [outerValue, isDirty]);

const onChangeHandler = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const nextValue = event.target.value;
if (onChange && !disabled) {
onChange(nextValue);
}
},
[onChange, disabled],
);

const classnames = useMemo(
() =>
cx(
"input",
{
disabled,
invalid,
active: !disabled && !invalid,
},
className,
),
[className, invalid, disabled],
);

return (
<input
{...props}
name={name}
type={type}
value={value}
placeholder={placeholder}
className={classnames}
style={style}
onChange={onChangeHandler}
autoComplete={autoComplete}
disabled={disabled}
/>
);
};
78 changes: 78 additions & 0 deletions packages/ui-components/src/Input/NumberInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import React, { useCallback, useState } from "react";
import classNames from "classnames/bind";
import { CaretUp, CaretDown } from "@cockroachlabs/icons";
import isNumber from "../utils/isNumber";
import { BaseInput, InputProps } from "./BaseInput";
import styles from "./styles.module.scss";
import { InputPrefix, InputWrapper } from "./helpers";

const cx = classNames.bind(styles);

export type NumberInputProps = Omit<InputProps<number>, "type">;

export const NumberInput: React.FC<NumberInputProps> = ({
onChange,
value: outerValue,
initialValue,
prefixIcon,
invalid,
disabled,
...props
}) => {
const [value, setValue] = useState<number>(outerValue || initialValue || 0);
const onSpinClickHandler = useCallback(
(increase: -1 | 1) => () => {
if (disabled) {
return;
}
const nextValue = value + increase;
setValue(nextValue);
onChange(nextValue);
},
[value, onChange, disabled],
);

const onChangeHandler = useCallback(
(nextValue: string) => {
const parsedValue = Number(nextValue);
if (!isNumber(parsedValue)) {
return;
}
setValue(parsedValue);
onChange(parsedValue);
},
[onChange],
);

const spinButtonsGroupClassName = cx("spin-buttons-group");
const spinButton = cx("spin-button");

return (
<InputWrapper disabled={disabled} invalid={invalid} className="number-type">
<InputPrefix>{prefixIcon}</InputPrefix>
<BaseInput
{...props}
disabled={disabled}
invalid={invalid}
onChange={onChangeHandler}
value={value}
initialValue={initialValue}
type="number"
/>
<div className={spinButtonsGroupClassName}>
<button
className={`${spinButton} spin-button-up`}
onClick={onSpinClickHandler(1)}
>
<CaretUp />
</button>
<button
className={`${spinButton} spin-button-down`}
onClick={onSpinClickHandler(-1)}
>
<CaretDown />
</button>
</div>
</InputWrapper>
);
};
24 changes: 24 additions & 0 deletions packages/ui-components/src/Input/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
[back to components](../README.md)

# Input components

Provides `<TextInput />` and `<NumberInput />` realizations and `<BaseInput />`
unstyled base component to build own custom input components.

## Properties
### `initialValue?: string | number`
Value to be displayed if no `value` is provided or nothing is entered by user.
### `value?: string | number`
Value to be displayed in input element
### `onChange?: (value: string | number) => void;`
Handler to be called when input value is changed by user
### `placeholder?: string;`
Placeholder to be shown when no value is present
### `disabled?: boolean;`
Disable input
### `invalid?: boolean;`
Highlight input element with red borders to indicate that entered value is not acceptable.
### `prefixIcon?: ReactNode`
Expects one of SVG icons (`@cockroachlabs/icons`) to be provided.


15 changes: 15 additions & 0 deletions packages/ui-components/src/Input/TextInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import React from "react";
import { BaseInput, InputProps } from "./BaseInput";
import { InputPrefix, InputWrapper } from "./helpers";

export type TextInputProps = Omit<InputProps<string>, "type">;

export const TextInput: React.FC<TextInputProps> = props => {
const { prefixIcon, disabled, invalid } = props;
return (
<InputWrapper disabled={disabled} invalid={invalid}>
<InputPrefix>{prefixIcon}</InputPrefix>
Copy link
Contributor

@nathanstilwell nathanstilwell Jun 3, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't want to hold up this PR any longer, but wanted to leave a note to come back to. Should we conditionally render this prefixIcon only if an icon is provided? Will React be smart enough to not render empty DOM elements?

<BaseInput {...props} type="text" />
</InputWrapper>
);
};
17 changes: 17 additions & 0 deletions packages/ui-components/src/Input/constants.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
@import "../styles/tokens.scss";

$input-height: 40px;
$input-border-spacing: 3px;
$border-width: 1px;
$input-number-width: 160px;
$input-text-width: 280px;

// state, hoverColor, focusColor
$state-colors:
"active" $crl-neutral-5 $crl-base-blue,
"invalid" $crl-base-red,
"disabled" $crl-base-text--light;

@function exclBorderWidth($total-width: 0, $spacing: $input-border-spacing, $border: $border-width) {
@return $total-width - ($border + $spacing) * 2;
}
44 changes: 44 additions & 0 deletions packages/ui-components/src/Input/helpers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import React from "react";
import classNames from "classnames/bind";
import styles from "./styles.module.scss";
import { InputProps } from "./BaseInput";

const cx = classNames.bind(styles);

export type InputWrapperProps = Pick<
InputProps,
"disabled" | "invalid" | "className"
>;

export interface InputPrefixProps {
className?: string;
}

export const InputPrefix: React.FC<InputPrefixProps> = ({
children,
className,
}) => {
if (!children) {
return null;
}
const classes = cx("prefix", className);
return <div className={classes}>{children}</div>;
};

export const InputWrapper: React.FC<InputWrapperProps> = ({
children,
className,
invalid,
disabled,
}) => {
const wrapperClassName = cx(
"container",
{
active: !invalid && !disabled,
disabled: disabled,
invalid: invalid,
},
className,
);
return <div className={wrapperClassName}>{children}</div>;
};
3 changes: 3 additions & 0 deletions packages/ui-components/src/Input/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./BaseInput";
export * from "./TextInput";
export * from "./NumberInput";
Loading