Skip to content

Commit

Permalink
feat: APP-2612 - Implement InputContainer and InputText components (#48)
Browse files Browse the repository at this point in the history
  • Loading branch information
cgero-eth authored Dec 12, 2023
1 parent ad2e955 commit 3bc6e27
Show file tree
Hide file tree
Showing 18 changed files with 523 additions and 7 deletions.
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

### Added

- Implement `Tag` component
- Implement `Tag`, `InputContainer` and `InputText` components
- Documentation on how to handle library dependencies
- `shadow-none` and `shake` Tailwind CSS utility classes

### Changed

Expand All @@ -20,6 +21,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- Prettier configuration to propertly format markdown files
- Bump `@adobe/css-tools` from 4.3.1 to 4.3.2

### Fixed

- Correctly format `README.md` links on Storybook

## [1.0.5] - 2023-11-20

### Changed
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

<p align="center">
<a href="https://aragon.org/">Aragon Website</a>
<span>&nbsp;&nbsp;</span>
<a href="https://devs.aragon.org/">Developer Portal</a>
<span>&nbsp;&nbsp;</span>
<a href="http://eepurl.com/icA7oj">Join our Developer Community</a>
<span>&nbsp;&nbsp;</span>
<a href="https://aragonproject.typeform.com/dx-contribution">Contribute</a>
</p>

Expand Down
9 changes: 7 additions & 2 deletions src/components/alerts/alertInline/alertInline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,13 @@ import { Icon } from '../../icon';
import { alertVariantToIconType, type AlertVariant } from '../utils';

export interface IAlertInlineProps extends HTMLAttributes<HTMLDivElement> {
/** Alert text content. */
/**
* Alert text content.
*/
message: string;
/** Defines the variant of the alert. */
/**
* Defines the variant of the alert.
*/
variant: AlertVariant;
}

Expand All @@ -29,6 +33,7 @@ const variantToTextClassNames: Record<AlertVariant, string> = {
/** AlertInline UI Component */
export const AlertInline: React.FC<IAlertInlineProps> = (props) => {
const { className, message, variant, ...rest } = props;

return (
<div role="alert" className={classNames('inline-flex items-center gap-x-2 rounded', className)} {...rest}>
<Icon
Expand Down
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export * from './avatars';
export * from './button';
export * from './icon';
export * from './illustrations';
export * from './input';
export * from './progress';
export * from './spinner';
export * from './tag';
2 changes: 2 additions & 0 deletions src/components/input/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './inputContainer';
export * from './inputText';
2 changes: 2 additions & 0 deletions src/components/input/inputContainer/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { InputContainer } from './inputContainer';
export type { IInputComponentProps, IInputContainerProps, InputVariant } from './inputContainer.api';
72 changes: 72 additions & 0 deletions src/components/input/inputContainer/inputContainer.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { InputHTMLAttributes, ReactNode } from 'react';
import type { AlertVariant } from '../../alerts/utils';

export type InputVariant = 'default' | 'warning' | 'critical';

export interface IInputContainerAlert {
/**
* Message to be displayed.
*/
message: string;
/**
* Variant of the alert.
*/
variant: Exclude<AlertVariant, 'info' | 'success'>;
}

export interface IInputContainerProps {
/**
* Label of the input.
*/
label?: string;
/**
* Variant of the input.
* @default default
*/
variant?: InputVariant;
/**
* Help text displayed above the input.
*/
helpText?: string;
/**
* Displays the optional tag when set to true.
*/
isOptional?: boolean;
/**
* Displays the input as disabled when set to true.
*/
isDisabled?: boolean;
/**
* Alert displayed below the input.
*/
alert?: IInputContainerAlert;
/**
* Id of the input field.
*/
id: string;
/**
* Displays an input length counter when set.
*/
maxLength?: number;
/**
* Current input length displayed when maxLength property is set.
*/
inputLength?: number;
/**
* Children of the component.
*/
children?: ReactNode;
/**
* Classes for the component.
*/
className?: string;
}

export interface IInputComponentProps
extends Omit<IInputContainerProps, 'children' | 'id' | 'inputLength'>,
Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
/**
* Classes for the input element.
*/
inputClassName?: string;
}
25 changes: 25 additions & 0 deletions src/components/input/inputContainer/inputContainer.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { Meta, StoryObj } from '@storybook/react';
import { InputContainer } from './inputContainer';

const meta: Meta<typeof InputContainer> = {
title: 'components/Input/InputContainer',
component: InputContainer,
tags: ['autodocs'],
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/jfKRr1V9evJUp1uBeyP3Zz/v1.0.0?type=design&node-id=10055-28606&mode=design&t=dehPZplRn0YEdOuB-4',
},
},
};

type Story = StoryObj<typeof InputContainer>;

/**
* Default usage example of the InputContainer component.
*/
export const Default: Story = {
args: {},
};

export default meta;
62 changes: 62 additions & 0 deletions src/components/input/inputContainer/inputContainer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { render, screen } from '@testing-library/react';
import { InputContainer } from './inputContainer';
import type { IInputContainerProps } from './inputContainer.api';

describe('<InputContainer /> component', () => {
const createTestComponent = (props?: Partial<IInputContainerProps>) => {
const completeProps = {
id: 'test',
...props,
};

return <InputContainer {...completeProps} />;
};

it('renders the input label when specified', () => {
const label = 'input-label';

// The getByLabelText requires a form control to be associated to the label
const id = 'input-id';
const children = <input id={id} />;

render(createTestComponent({ label, children, id }));
expect(screen.getByLabelText(label)).toBeInTheDocument();
});

it('renders the optional tag when the label is set and isOptional prop is set to true', () => {
const label = 'label-test';
const isOptional = true;
render(createTestComponent({ label, isOptional }));
expect(screen.getByText('Optional')).toBeInTheDocument();
});

it('renders the help text when defined', () => {
const helpText = 'help-text-test';
render(createTestComponent({ helpText }));
expect(screen.getByText(helpText)).toBeInTheDocument();
});

it('renders the input value counter when maxLength is defined', () => {
const maxLength = 100;
const inputLength = 47;
render(createTestComponent({ maxLength, inputLength }));
expect(screen.getByText(`[${inputLength}/${maxLength}]`)).toBeInTheDocument();
});

it('adds a shake animation when the input length is equal to the max length property', () => {
const maxLength = 10;
const inputLength = 10;
render(createTestComponent({ maxLength, inputLength }));
expect(screen.getByText(`[${inputLength}/${maxLength}]`).className).toContain('shake');
});

it('renders the input alert when defined', () => {
const alert = {
message: 'input-alert-message',
variant: 'critical' as const,
};
render(createTestComponent({ alert }));
expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.getByText(alert.message)).toBeInTheDocument();
});
});
85 changes: 85 additions & 0 deletions src/components/input/inputContainer/inputContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import classNames from 'classnames';
import { AlertInline } from '../../alerts';
import { Tag } from '../../tag';
import type { IInputContainerProps, InputVariant } from './inputContainer.api';

const variantToClassNames: Record<InputVariant | 'disabled', string[]> = {
default: [
'border-neutral-100 bg-neutral-0', // Default state
'hover:border-neutral-200 hover:shadow-neutral-md', // Hover state
'focus-within:outline-primary-400 focus-within:border-primary-400 focus-within:shadow-primary-md', // Focus state
'focus-within:hover:border-primary-400 focus-within:hover:shadow-primary-md', // Focus + Hover state
],
warning: [
'border-warning-500 bg-neutral-0', // Default state
'hover:border-warning-600 hover:shadow-warning-md', // Hover state
'focus-within:outline-warning-600 focus-within:border-warning-600 focus-within:shadow-warning-md', // Focus state
'focus-within:hover:border-warning-600 focus-within:hover:shadow-warning-md', // Focus + Hover state
],
critical: [
'border-critical-500 bg-neutral-0', // Default state
'hover:border-critical-600 hover:shadow-critical-md', // Hover state
'focus-within:outline-critical-600 focus-within:border-critical-600 focus-within:shadow-critical-md', // Focus state
'focus-within:hover:border-critical-600 focus-within:hover:shadow-critical-md', // Focus + Hover state
],
disabled: ['border-neutral-200 bg-neutral-100'],
};

/**
* The InputContainer component provides a consistent and shared styling foundation for various input components, such
* as `InputText`, `InputNumber` and others. It also manages properties that are shared across all input components,
* including `label`, `helpText` and more.
*/
export const InputContainer: React.FC<IInputContainerProps> = (props) => {
const {
label,
variant = 'default',
helpText,
isOptional,
maxLength,
inputLength = 0,
alert,
isDisabled,
children,
className,
id,
} = props;

const processedVariant = isDisabled ? 'disabled' : variant;
const containerClasses = classNames(
'h-12 w-full rounded-xl border text-neutral-600 transition-all', // Default
'outline-1 focus-within:outline', // Outline on focus
'text-base font-normal leading-tight', // Typography
variantToClassNames[processedVariant],
);

const counterClasses = classNames('text-sm font-normal leading-tight text-neutral-600', {
'animate-shake': inputLength === maxLength,
});

return (
<div className={classNames('flex grow flex-col gap-2 md:gap-3', className)}>
{(label != null || helpText != null) && (
<label className="flex flex-col gap-0.5 md:gap-1" htmlFor={id}>
{label && (
<div className="flex flex-row items-center gap-3">
<p className="text-base font-semibold leading-normal text-neutral-600 md:text-lg md:leading-tight">
{label}
</p>
{/* TODO: apply internationalisation to Optional label [APP-2627] */}
{isOptional && <Tag variant="neutral" label="Optional" />}
</div>
)}
{helpText && <p className="text-sm font-normal leading-normal text-neutral-800">{helpText}</p>}
</label>
)}
<div className={containerClasses}>{children}</div>
{maxLength != null && (
<p className={counterClasses}>
[{inputLength}/{maxLength}]
</p>
)}
{alert && <AlertInline variant={alert.variant} message={alert.message} />}
</div>
);
};
1 change: 1 addition & 0 deletions src/components/input/inputText/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { InputText, type IInputTextProps } from './inputText';
46 changes: 46 additions & 0 deletions src/components/input/inputText/inputText.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from '@storybook/react';
import { useState, type ChangeEvent } from 'react';
import { InputText, type IInputTextProps } from './inputText';

const meta: Meta<typeof InputText> = {
title: 'components/Input/InputText',
component: InputText,
tags: ['autodocs'],
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/jfKRr1V9evJUp1uBeyP3Zz/v1.0.0?type=design&node-id=17-292&mode=design&t=dehPZplRn0YEdOuB-4',
},
},
};

type Story = StoryObj<typeof InputText>;

/**
* Default uncontrolled usage example of the InputText component.
*/
export const Default: Story = {
args: {
placeholder: 'Uncontrolled input',
},
};

const ControlledComponent = (props: IInputTextProps) => {
const [value, setValue] = useState<string>('');

const handleChange = (event: ChangeEvent<HTMLInputElement>) => setValue(event.target.value);

return <InputText value={value} onChange={handleChange} {...props} />;
};

/**
* Usage example of a controlled input.
*/
export const Controlled: Story = {
render: (props) => <ControlledComponent {...props} />,
args: {
placeholder: 'Controlled input',
},
};

export default meta;
27 changes: 27 additions & 0 deletions src/components/input/inputText/inputText.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { render, screen } from '@testing-library/react';
import { InputText, type IInputTextProps } from './inputText';

describe('<InputText /> component', () => {
const createTestComponent = (props?: Partial<IInputTextProps>) => {
const completeProps = { ...props };

return <InputText {...completeProps} />;
};

it('renders a text input element', () => {
render(createTestComponent());
expect(screen.getByRole('textbox')).toBeInTheDocument();
});

it('disables the text input when isDisabled is set to true', () => {
const isDisabled = true;
render(createTestComponent({ isDisabled }));
expect(screen.getByRole<HTMLInputElement>('textbox').disabled).toBeTruthy();
});

it('applies the input class names when defined', () => {
const inputClassName = 'class-test';
render(createTestComponent({ inputClassName }));
expect(screen.getByRole('textbox').className).toContain(inputClassName);
});
});
Loading

0 comments on commit 3bc6e27

Please sign in to comment.