Skip to content
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
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// @flow
/* eslint-disable react-hooks/rules-of-hooks */
import * as React from 'react';

Expand Down
112 changes: 112 additions & 0 deletions src/components/text-input/TextInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import * as React from 'react';
import classNames from 'classnames';
import uniqueId from 'lodash/uniqueId';

import IconVerified from '../../icons/general/IconVerified';

import Label from '../label';
import LoadingIndicator from '../loading-indicator';
import Tooltip, { TooltipPosition, TooltipTheme, type TooltipProps } from '../tooltip';

import './TextInput.scss';

export interface TextInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
/** Add a class to the component */
className?: string;
/** Description shown below the label */
description?: React.ReactNode;
/** Error message shown in the error tooltip */
error?: React.ReactNode;
/** Renders error tooltip at the specified position (positions are those from Tooltip) */
errorPosition?: NonNullable<TooltipProps['position']>;
/** Hides the label */
hideLabel?: boolean;
/** Hides (optional) text from the label */
hideOptionalLabel?: boolean;
/** Icon to display in the input field */
icon?: React.ReactNode;
/** Ref to the underlying input element. @TODO: eventually rename to innerRef for consistancy across all form elements */
inputRef?: React.Ref<HTMLInputElement>;
/** Renders a loading indicator within the component when true */
isLoading?: boolean;
/** Makes the input value required */
isRequired?: boolean;
/** Renders a green verified checkmark within the component when true */
isValid?: boolean;
/** Label displayed for the text input */
label: React.ReactNode;
/** Tooltip shown on the label */
labelTooltip?: React.ReactNode;
/** A CSS class for the tooltip's tether element component */
tooltipTetherClassName?: string;
/** A CSS class for the tooltip's target wrapper element */
tooltipWrapperClassName?: string;
}

const TextInput = ({
className = '',
description,
error,
errorPosition,
hideLabel,
hideOptionalLabel,
icon,
inputRef,
isLoading,
isRequired,
isValid,
label,
labelTooltip,
tooltipTetherClassName: tetherElementClassName,
tooltipWrapperClassName,
...rest
}: TextInputProps) => {
const hasError = !!error;
const classes = classNames(className, 'text-input-container', {
'show-error': hasError,
});

const descriptionID = React.useRef(uniqueId('description')).current;

const ariaAttrs = {
'aria-invalid': hasError,
'aria-required': isRequired,
'aria-describedby': description ? descriptionID : undefined,
};

return (
<div className={classes}>
<Label
hideLabel={hideLabel}
showOptionalText={!hideOptionalLabel && !isRequired}
text={label}
tooltip={labelTooltip}
>
<>
{!!description && (
<div id={descriptionID} className="text-input-description">
{description}
</div>
)}
<Tooltip
isShown={hasError}
position={errorPosition || TooltipPosition.MIDDLE_RIGHT}
targetWrapperClassName={tooltipWrapperClassName}
tetherElementClassName={tetherElementClassName}
text={error || ''}
theme={TooltipTheme.ERROR}
>
<input ref={inputRef} required={isRequired} {...ariaAttrs} {...rest} />
Comment thread
bonchevskyi marked this conversation as resolved.
</Tooltip>
{isLoading && !isValid && <LoadingIndicator className="text-input-loading" />}
{isValid && !isLoading && <IconVerified className="text-input-verified" />}
{!isLoading && !isValid && icon ? icon : null}
</>
</Label>
</div>
);
};

TextInput.displayName = 'TextInput';

export default TextInput;
29 changes: 29 additions & 0 deletions src/components/text-input/TextInputField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as React from 'react';
import getProp from 'lodash/get';
import type { FieldProps } from 'formik';

import TextInputPrimitive from './TextInput';
import type { TextInputProps } from './TextInput';

export interface TextInputFieldProps extends Omit<TextInputProps, 'form'>, FieldProps {
/** Ref forwarded to the underlying input element as inputRef */
innerRef?: (instance: HTMLInputElement | null) => void;
}

const TextInputField = ({ field, form, innerRef, isRequired, ...rest }: TextInputFieldProps) => {
const { name } = field;
const { errors, touched } = form;
const isTouched = getProp(touched, name);
const error = isTouched ? getProp(errors, name) : null;
return (
<TextInputPrimitive
{...field}
{...rest}
inputRef={innerRef}
error={error as React.ReactNode}
hideOptionalLabel={isRequired}
/>
Comment thread
bonchevskyi marked this conversation as resolved.
);
};

export default TextInputField;
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import TetherComponent from 'react-tether';
import ClockBadge16 from '../../../icon/line/ClockBadge16';
import IconVerified from '../../../icons/general/IconVerified';
import LoadingIndicator from '../../loading-indicator';
import { TooltipPosition } from '../../tooltip';
import TextInput from '..';

jest.mock('lodash/uniqueId', () => () => 'description20');
Expand Down Expand Up @@ -68,10 +69,12 @@ describe('components/text-input/TextInput', () => {
});

test('should show Tooltip for an error at a custom position', () => {
const wrapper = shallow(<TextInput error="error" errorPosition="bottom-center" label="label" />);
const wrapper = shallow(
<TextInput error="error" errorPosition={TooltipPosition.BOTTOM_CENTER} label="label" />,
);

const tooltip = wrapper.find('Tooltip');
expect(tooltip.prop('position')).toBe('bottom-center');
expect(tooltip.prop('position')).toBe(TooltipPosition.BOTTOM_CENTER);
});

test('should not show Tooltip when no error exists', () => {
Expand All @@ -90,7 +93,7 @@ describe('components/text-input/TextInput', () => {
});

test('should render text input with description', () => {
const wrapper = shallow(<TextInput description="some description" />);
const wrapper = shallow(<TextInput description="some description" label="label" />);

expect(wrapper).toMatchSnapshot();
});
Expand All @@ -107,7 +110,7 @@ describe('components/text-input/TextInput', () => {
`(
'should render $description',
({ isLoading, isValid, icon, loadingIndicatorExists, validIconExists, customIconExists }) => {
const wrapper = shallow(<TextInput icon={icon} isLoading={isLoading} isValid={isValid} />);
const wrapper = shallow(<TextInput icon={icon} isLoading={isLoading} isValid={isValid} label="label" />);
if (icon) {
expect(wrapper.exists(ClockBadge16)).toBe(customIconExists);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
// @flow

import * as React from 'react';
import { shallow } from 'enzyme';

import TextInputField from '../TextInputField';

describe('components/text-input/TextInputField', () => {
const getWrapper = (props = {}) => shallow(<TextInputField {...props} />);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const getWrapper = (props: any = {}) => shallow(<TextInputField {...props} />);

test('should render properly', () => {
const wrapper = getWrapper({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ exports[`components/text-input/TextInput should render text input with descripti
>
<Label
showOptionalText={true}
text="label"
>
<div
className="text-input-description"
Expand Down
4 changes: 4 additions & 0 deletions src/components/text-input/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { default } from './TextInput';
export { default as TextInputField } from './TextInputField';
export type { TextInputProps } from './TextInput';
export type { TextInputFieldProps } from './TextInputField';
2 changes: 1 addition & 1 deletion src/components/time-input/TimeInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ const TimeInput = ({
label={label}
onBlur={handleBlur}
onChange={handleChange}
position={errorTooltipPosition}
Comment thread
bonchevskyi marked this conversation as resolved.
errorPosition={errorTooltipPosition}
Comment thread
bonchevskyi marked this conversation as resolved.
type="text"
value={displayTime}
/>
Expand Down
Loading