Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: APP-2744 - Implement TextAreaRichText component #71

Merged
merged 18 commits into from
Jan 31, 2024
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
7 changes: 5 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

### Added

- Implement `Link`, `InputNumber` and `InputTime` components
- Implement 'Addon' element for `InputText` component
- Implement `Link`, `InputNumber` `InputTime` and `TextAreaRichText` components
- Implement Addon element for `InputText` component
- Handle size property on `Progress` component
- `border-none` Tailwind CSS utility class

### Changed

- Update minor and patch versions of dependencies
- Update `husky` to v9
- Add `wrapperClassName` property to `InputContainer` component to customise the input wrapper
- Update `InputContainer` props to accept any HTML div property and support textarea elements

## [1.0.9] - 2024-01-23

Expand Down
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,19 @@
"@radix-ui/react-progress": "^1.0.0",
"@radix-ui/react-switch": "^1.0.0",
"@radix-ui/react-toggle-group": "^1.0.0",
"@tiptap/extension-link": "^2.1.0",
"@tiptap/extension-placeholder": "^2.1.0",
"@tiptap/pm": "^2.1.0",
"@tiptap/react": "^2.1.0",
"@tiptap/starter-kit": "^2.1.0",
"classnames": "^2.0.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-imask": "^7.3.0",
"react-merge-refs": "^2.0.0"
},
"peerDependencies": {
"@tailwindcss/typography": "^0.5.0",
"tailwindcss": "^3.4.0"
},
"devDependencies": {
Expand All @@ -76,6 +82,7 @@
"@storybook/testing-library": "^0.2.2",
"@svgr/rollup": "^8.1.0",
"@svgr/webpack": "^8.1.0",
"@tailwindcss/typography": "^0.5.10",
"@testing-library/jest-dom": "^6.4.0",
"@testing-library/react": "^14.1.2",
"@types/jest": "^29.5.11",
Expand Down
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export * from './progress';
export * from './spinner';
export * from './switch';
export * from './tag';
export * from './textAreas';
export * from './toggles';
14 changes: 9 additions & 5 deletions src/components/input/hooks/useInputProps.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
import classNames from 'classnames';
import { useEffect, useId, useState, type ChangeEvent, type InputHTMLAttributes } from 'react';
import type { IInputComponentProps, IInputContainerProps } from '../inputContainer';
import type { IInputComponentProps, IInputContainerProps, InputComponentElement } from '../inputContainer';

export interface IUseInputPropsResult {
export interface IUseInputPropsResult<TElement extends InputComponentElement> {
/**
* Properties for the InputContainer component.
*/
containerProps: IInputContainerProps;
/**
* Properties for the input element.
*/
inputProps: InputHTMLAttributes<HTMLInputElement>;
inputProps: InputHTMLAttributes<TElement>;
}

/**
* Processes the InputComponent properties object to split it into container-specific and input-element-specific properties.
* @param props The InputComponent properties
* @returns The InputContainer and input element properties.
*/
export const useInputProps = (props: IInputComponentProps): IUseInputPropsResult => {
export const useInputProps = <TElement extends InputComponentElement>(
props: IInputComponentProps<TElement>,
): IUseInputPropsResult<TElement> => {
const {
label,
variant,
Expand All @@ -32,6 +34,7 @@ export const useInputProps = (props: IInputComponentProps): IUseInputPropsResult
maxLength,
onChange,
value,
wrapperClassName,
...inputElementProps
} = props;

Expand All @@ -42,7 +45,7 @@ export const useInputProps = (props: IInputComponentProps): IUseInputPropsResult

const [inputLength, setInputLength] = useState(0);

const handleOnChange = (event: ChangeEvent<HTMLInputElement>) => {
const handleOnChange = (event: ChangeEvent<TElement>) => {
setInputLength(event.target.value.length);
onChange?.(event);
};
Expand All @@ -58,6 +61,7 @@ export const useInputProps = (props: IInputComponentProps): IUseInputPropsResult
maxLength,
className,
inputLength,
wrapperClassName,
};

const inputClasses = classNames(
Expand Down
7 changes: 6 additions & 1 deletion src/components/input/inputContainer/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
export { InputContainer } from './inputContainer';
export type { IInputComponentProps, IInputContainerProps, InputVariant } from './inputContainer.api';
export type {
IInputComponentProps,
IInputContainerProps,
InputComponentElement,
InputVariant,
} from './inputContainer.api';
14 changes: 9 additions & 5 deletions src/components/input/inputContainer/inputContainer.api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { InputHTMLAttributes, ReactNode } from 'react';
import type { ComponentPropsWithRef, InputHTMLAttributes, ReactNode } from 'react';
import type { AlertVariant } from '../../alerts/utils';

export type InputVariant = 'default' | 'warning' | 'critical';
Expand All @@ -14,7 +14,7 @@ export interface IInputContainerAlert {
variant: Exclude<AlertVariant, 'info' | 'success'>;
}

export interface IInputContainerProps {
export interface IInputContainerBaseProps {
/**
* Label of the input.
*/
Expand Down Expand Up @@ -66,9 +66,13 @@ export interface IInputContainerProps {
wrapperClassName?: string;
}

export interface IInputComponentProps
extends Omit<IInputContainerProps, 'children' | 'id' | 'inputLength'>,
Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
export interface IInputContainerProps extends IInputContainerBaseProps, Omit<ComponentPropsWithRef<'div'>, 'id'> {}

export type InputComponentElement = HTMLInputElement | HTMLTextAreaElement;

export interface IInputComponentProps<TElement extends InputComponentElement = HTMLInputElement>
extends Omit<IInputContainerBaseProps, 'children' | 'id' | 'inputLength'>,
Omit<InputHTMLAttributes<TElement>, 'type'> {
/**
* Classes for the input element.
*/
Expand Down
12 changes: 8 additions & 4 deletions src/components/input/inputContainer/inputContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import classNames from 'classnames';
import { forwardRef } from 'react';
import { AlertInline } from '../../alerts';
import { Tag } from '../../tag';
import type { IInputContainerProps, InputVariant } from './inputContainer.api';
Expand Down Expand Up @@ -30,7 +31,7 @@ const variantToClassNames: Record<InputVariant | 'disabled', string[]> = {
* 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) => {
export const InputContainer = forwardRef<HTMLDivElement, IInputContainerProps>((props, ref) => {
const {
label,
variant = 'default',
Expand All @@ -44,11 +45,12 @@ export const InputContainer: React.FC<IInputContainerProps> = (props) => {
className,
wrapperClassName,
id,
...otherProps
} = props;

const processedVariant = isDisabled ? 'disabled' : variant;
const containerClasses = classNames(
'flex h-12 w-full flex-row items-center', // Layout
'flex min-h-12 w-full flex-row items-center', // Layout
'rounded-xl border text-neutral-600 transition-all', // Styling
'outline-1 focus-within:outline', // Outline on focus
'text-base font-normal leading-tight', // Typography
Expand All @@ -61,7 +63,7 @@ export const InputContainer: React.FC<IInputContainerProps> = (props) => {
});

return (
<div className={classNames('flex grow flex-col gap-2 md:gap-3', className)}>
<div className={classNames('flex grow flex-col gap-2 md:gap-3', className)} ref={ref} {...otherProps}>
{(label != null || helpText != null) && (
<label className="flex flex-col gap-0.5 md:gap-1" htmlFor={id}>
{label && (
Expand All @@ -85,4 +87,6 @@ export const InputContainer: React.FC<IInputContainerProps> = (props) => {
{alert && <AlertInline variant={alert.variant} message={alert.message} />}
</div>
);
};
});

InputContainer.displayName = 'InputContainer';
1 change: 1 addition & 0 deletions src/components/textAreas/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './textAreaRichText';
1 change: 1 addition & 0 deletions src/components/textAreas/textAreaRichText/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { TextAreaRichText, type ITextAreaRichTextProps } from './textAreaRichText';
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';
import { TextAreaRichText, type ITextAreaRichTextProps } from './textAreaRichText';

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

type Story = StoryObj<typeof TextAreaRichText>;

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

const ControlledComponent = (props: ITextAreaRichTextProps) => {
const [value, setValue] = useState(
'<p>Hello <strong>dev</strong>, check this <a href="https://aragon.org" target="_blank">link</a>.</p>',
);

return <TextAreaRichText value={value} onChange={setValue} {...props} />;
};

/**
* Usage example of a controlled TextAreaRichText component.
*/
export const Controlled: Story = {
render: ({ onChange, ...props }) => <ControlledComponent {...props} />,
};

export default meta;
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { fireEvent, render, screen } from '@testing-library/react';
import ReactDOM from 'react-dom';
import { IconType } from '../../icon';
import { TextAreaRichText, type ITextAreaRichTextProps } from './textAreaRichText';

describe('<TextAreaRichText /> component', () => {
(global.ClipboardEvent as unknown) = class ClipboardEventMock {};
(global.DragEvent as unknown) = class DragEventMock {};

const createPortalMock = jest.spyOn(ReactDOM, 'createPortal');

afterEach(() => {
createPortalMock.mockReset();
});

const createTestComponent = (props?: Partial<ITextAreaRichTextProps>) => {
const completeProps: ITextAreaRichTextProps = { ...props };

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

it('renders a textbox with the specified id', () => {
const id = 'testid';
render(createTestComponent({ id }));
const component = screen.getByRole('textbox');
expect(component).toBeInTheDocument();
expect(component.getAttribute('contenteditable')).toEqual('true');
expect(component.getAttribute('aria-labelledby')).toEqual(id);
});

it('renders the rich text actions', () => {
render(createTestComponent());
expect(screen.getAllByRole('button').length).toBeGreaterThan(0);
});

it('disables the textbox when the isDisabled property is set to true', () => {
const isDisabled = true;
render(createTestComponent({ isDisabled }));
expect(screen.getByRole('textbox').getAttribute('contenteditable')).toEqual('false');
});

it('generates a random id for the textbox when the id property is not set', () => {
render(createTestComponent());
expect(screen.getByRole('textbox').getAttribute('aria-labelledby')).toBeDefined();
});

it('calls the onChange property on input change', () => {
const onChange = jest.fn();
render(createTestComponent({ onChange }));
fireEvent.input(screen.getByRole('textbox'), 'test');
expect(onChange).toHaveBeenCalled();
});

it('renders the textarea as a React portal on expand action click', () => {
render(createTestComponent());
fireEvent.click(screen.getByTestId(IconType.EXPAND));
expect(createPortalMock).toHaveBeenCalled();
expect(document.body.style.overflow).toEqual('hidden');
});

it('reset the expanded state on ESC key down', () => {
render(createTestComponent());
fireEvent.click(screen.getByTestId(IconType.EXPAND));
fireEvent.keyDown(window, { key: 'Escape' });
expect(document.body.style.overflow).toEqual('auto');
});
});
Loading
Loading