Skip to content

Commit

Permalink
feat: APP-2715 - Implement EmptyState Component (#77)
Browse files Browse the repository at this point in the history
  • Loading branch information
thekidnamedkd authored Feb 7, 2024
1 parent b348478 commit 7d35e4b
Show file tree
Hide file tree
Showing 9 changed files with 357 additions and 3 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

### Added

- Implement `Checkbox`, `CheckboxGroup`, `RadioGroup`, `Radio`, and `RadioCard` components
- Implement `EmptyState`, `Checkbox`, `CheckboxGroup`, `RadioGroup`, `Radio`, and `RadioCard` components

## [1.0.11] - 2024-02-06

Expand Down
5 changes: 3 additions & 2 deletions src/components/button/button.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,6 @@ export interface IButtonBaseProps {
iconLeft?: IconType;
}

export type IButtonProps = IButtonBaseProps &
(ButtonHTMLAttributes<HTMLButtonElement> | AnchorHTMLAttributes<HTMLAnchorElement>);
export type IButtonElementProps = ButtonHTMLAttributes<HTMLButtonElement> | AnchorHTMLAttributes<HTMLAnchorElement>;

export type IButtonProps = IButtonBaseProps & IButtonElementProps;
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export * from './input';
export * from './progress';
export * from './radioGroup';
export * from './spinner';
export * from './states';
export * from './switch';
export * from './tag';
export * from './textAreas';
Expand Down
56 changes: 56 additions & 0 deletions src/components/states/emptyState/emptyState.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { IButtonBaseProps, IButtonElementProps } from '../../button/button.api';
import type { IIllustrationHumanProps, IIllustrationObjectProps } from '../../illustrations';

export interface IEmptyStateBaseProps {
/**
* Title of the empty state.
*/
heading: string;
/**
* Description of the empty state.
*/
description?: string;
/**
* Renders the state as horitontal when set to false.
* @default true
*/
isStacked?: boolean;
/**
* Primary button of the empty state. The primary button is only rendered on the stacked variant.
*/
primaryButton?: IEmptyStateButton;
/**
* Secondary button of the empty state.
*/
secondaryButton?: IEmptyStateButton;
/**
* Additional class names to be added to the empty state.
*/
className?: string;
}

export type IEmptyStateButton = Omit<IButtonBaseProps, 'variant' | 'size' | 'children'> &
IButtonElementProps & {
/**
* Button label to be rendered.
*/
label: string;
};

export interface IEmptyStateHumanIllustrationProps extends IEmptyStateBaseProps {
/**
* @see IIllustrationHumanProps
*/
humanIllustration: IIllustrationHumanProps;
objectIllustration?: never;
}

export interface IEmptyStateObjectIllustrationProps extends IEmptyStateBaseProps {
/**
* @see IIllustrationObjectProps
*/
objectIllustration: IIllustrationObjectProps;
humanIllustration?: never;
}

export type IEmptyStateProps = IEmptyStateHumanIllustrationProps | IEmptyStateObjectIllustrationProps;
106 changes: 106 additions & 0 deletions src/components/states/emptyState/emptyState.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { Meta, StoryObj } from '@storybook/react';
import { IconType } from '../../icon';
import { EmptyState } from './emptyState';

const meta: Meta<typeof EmptyState> = {
title: 'components/States/EmptyState',
component: EmptyState,
tags: ['autodocs'],
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/ISSDryshtEpB7SUSdNqAcw/branch/jfKRr1V9evJUp1uBeyP3Zz/Aragon-ODS?type=design&node-id=10095%3A21633&mode=dev&t=FtMO7nBXAzYBFGaW-1',
},
},
};

type Story = StoryObj<typeof EmptyState>;

/**
* Default EmptyState component with minimum props.
*/
export const Default: Story = {
args: {
heading: 'Heading',
description: 'Description',
objectIllustration: { object: 'LIGHTBULB' },
},
};

/**
* Stacked EmptyState component with full props examples for Object Illustration.
*/
export const StackedFullWithObject: Story = {
args: {
heading: 'Heading',
description: 'Description',
objectIllustration: { object: 'LIGHTBULB' },
primaryButton: {
label: 'Label',
iconLeft: IconType.ADD,
iconRight: IconType.CHEVRON_RIGHT,
onClick: () => alert('Primary Button Clicked'),
},
secondaryButton: {
label: 'Label',
iconLeft: IconType.ADD,
iconRight: IconType.CHEVRON_RIGHT,
onClick: () => alert('Secondary Button Clicked'),
},
},
};
/**
* Non-Stacked EmptyState component with full props examples for Object Illustration. <br />
* **Warning:** Non-Stacked EmptyState with Human Illustration is not supported visually.
* As displayed, use an object illustration instead for best layout.
*/
export const NonStackedFullWithObject: Story = {
args: {
heading: 'Heading',
description: 'Description',
isStacked: false,
objectIllustration: { object: 'LIGHTBULB' },
primaryButton: {
label: 'Label',
iconLeft: IconType.ADD,
iconRight: IconType.CHEVRON_RIGHT,
onClick: () => alert('Primary Button Clicked'),
},
secondaryButton: {
label: 'Label',
iconLeft: IconType.ADD,
iconRight: IconType.CHEVRON_RIGHT,
onClick: () => alert('Secondary Button Clicked'),
},
},
};
/**
* Stacked EmptyState component with full props examples for Human Illustation.
*/
export const StackedFullWithHuman: Story = {
args: {
heading: 'Heading',
description: 'Description',
humanIllustration: {
body: 'VOTING',
hairs: 'MIDDLE',
accessory: 'EARRINGS_RHOMBUS',
sunglasses: 'BIG_ROUNDED',
expression: 'SMILE',
},
primaryButton: {
label: 'Label',
iconLeft: IconType.ADD,
iconRight: IconType.CHEVRON_RIGHT,
onClick: () => alert('Primary Button Clicked'),
},
secondaryButton: {
label: 'Label',
iconLeft: IconType.ADD,
iconRight: IconType.CHEVRON_RIGHT,
onClick: () => alert('Secondary Button Clicked'),
},
},
};

export default meta;
72 changes: 72 additions & 0 deletions src/components/states/emptyState/emptyState.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { render, screen } from '@testing-library/react';
import { EmptyState } from './emptyState';
import type { IEmptyStateProps } from './emptyState.api';

describe('<EmptyState /> component', () => {
const createTestComponent = (props?: Partial<IEmptyStateProps>) => {
const commonProps = {
heading: 'test-heading',
};

if (props?.humanIllustration) {
return <EmptyState humanIllustration={props.humanIllustration} {...commonProps} {...props} />;
} else {
const { humanIllustration, objectIllustration = { object: 'ACTION' }, ...otherProps } = props ?? {};

return <EmptyState objectIllustration={objectIllustration} {...commonProps} {...otherProps} />;
}
};

it('renders the EmptyState component stacked with full props and object illustration', () => {
const objectIllustration = { object: 'LIGHTBULB' } as const;
const primaryButton = { label: 'Label' };
const secondaryButton = { label: 'Label' };
render(createTestComponent({ objectIllustration, primaryButton, secondaryButton }));

expect(screen.getByText('test-heading')).toBeInTheDocument();
const buttons = screen.getAllByRole('button', { name: 'Label' });
expect(buttons).toHaveLength(2);
expect(screen.getByTestId('LIGHTBULB')).toBeInTheDocument();
});

it('renders the EmptyState component stacked with full props and human illustration', () => {
const humanIllustration = { body: 'VOTING', expression: 'SMILE' } as const;
const primaryButton = { label: 'Label' };
const secondaryButton = { label: 'Label' };
render(createTestComponent({ humanIllustration, primaryButton, secondaryButton }));

expect(screen.getByText('test-heading')).toBeInTheDocument();
const buttons = screen.getAllByRole('button', { name: 'Label' });
expect(buttons).toHaveLength(2);
expect(screen.getByTestId('VOTING')).toBeInTheDocument();
});

it('renders the EmptyState component unstacked with full props and object illustration', () => {
const objectIllustration = { object: 'LIGHTBULB' } as const;
const primaryButton = { label: 'Label' };
const secondaryButton = { label: 'Label' };
const isStacked = false;
render(createTestComponent({ isStacked, objectIllustration, primaryButton, secondaryButton }));

expect(screen.getByText('test-heading')).toBeInTheDocument();
const buttons = screen.getAllByRole('button', { name: 'Label' });
expect(buttons).toHaveLength(2);
const objectImage = screen.getByTestId('LIGHTBULB');
expect(objectImage).toHaveClass('order-last');
});

it('renders the EmptyState component unstacked with full props and human illustration', () => {
const humanIllustration = { body: 'VOTING', expression: 'SMILE' } as const;
const primaryButton = { label: 'Label' };
const secondaryButton = { label: 'Label' };
const isStacked = false;
render(createTestComponent({ isStacked, humanIllustration, primaryButton, secondaryButton }));

expect(screen.getByText('test-heading')).toBeInTheDocument();
const buttons = screen.getAllByRole('button', { name: 'Label' });
expect(buttons).toHaveLength(2);
const humanImage = screen.getByTestId('VOTING');
// eslint-disable-next-line testing-library/no-node-access -- testid for SVG is nearest accessible attribute and reliable to the illustration
expect(humanImage.parentElement).toHaveClass('order-last');
});
});
109 changes: 109 additions & 0 deletions src/components/states/emptyState/emptyState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import classNames from 'classnames';
import { Button } from '../../button';
import { IllustrationHuman, IllustrationObject } from '../../illustrations';
import type { IEmptyStateProps } from './emptyState.api';

export const EmptyState: React.FC<IEmptyStateProps> = ({
heading,
description,
primaryButton,
secondaryButton,
className,
isStacked = true,
objectIllustration,
humanIllustration,
}) => {
const containerClassNames = classNames(
'grid w-[320px] md:w-[640px]',
{ 'grid-cols-1 justify-items-center p-6 md:p-12 gap-4 md:gap-6': isStacked },
{
'grid-cols-[auto_max-content] md:grid-cols-[auto_max-content] gap-4 p-4 md:px-6 md:py-5 items-center':
!isStacked,
},
className,
);

return (
<div className={containerClassNames}>
{humanIllustration && (
<IllustrationHuman
className={classNames({
'mb-4 h-auto !w-[295px] md:mb-6 md:!w-[400px]': isStacked,
'align-self-center order-last !w-[80px] justify-self-end md:!w-[172px]': !isStacked,
})}
{...humanIllustration}
/>
)}
{objectIllustration && (
<IllustrationObject
className={classNames({
'h-auto w-[160px]': isStacked,
'order-last h-auto w-[80px] justify-self-end rounded-full bg-neutral-50 md:w-[96px]':
!isStacked,
})}
{...objectIllustration}
/>
)}

<div
className={classNames('h-full', {
'flex w-full flex-col items-center': isStacked,
'space-y-6': (isStacked && !!primaryButton) || !!secondaryButton,
'space-y-4': (!isStacked && !!primaryButton) || !!secondaryButton,
})}
>
<div
className={classNames({
'flex flex-col items-center space-y-1 md:space-y-2': isStacked,
'items-start space-y-0.5 md:space-y-1': !isStacked,
})}
>
<p
className={classNames('font-normal leading-tight text-neutral-800', {
'text-xl md:text-2xl': isStacked,
'text-base md:text-lg': !isStacked,
})}
>
{heading}
</p>
<p
className={classNames('font-normal leading-tight text-neutral-500', {
'text-sm md:text-base': isStacked,
'text-xs md:text-sm': !isStacked,
})}
>
{description}
</p>
</div>
<div
className={classNames({
'border-w-full flex flex-col items-stretch space-x-0 space-y-3 md:flex-row md:justify-center md:space-x-4 md:space-y-0':
isStacked,
'flex flex-row flex-wrap gap-3': !isStacked,
})}
>
{primaryButton && (
<Button
{...primaryButton}
size={isStacked ? 'lg' : 'sm'}
responsiveSize={isStacked ? { md: 'lg' } : { md: 'md' }}
variant="primary"
>
{primaryButton.label}
</Button>
)}
{secondaryButton && (
<Button
{...secondaryButton}
size={isStacked ? 'lg' : 'sm'}
responsiveSize={isStacked ? { md: 'lg' } : { md: 'md' }}
variant="secondary"
>
{secondaryButton.label}
</Button>
)}
</div>
</div>
</div>
);
};
8 changes: 8 additions & 0 deletions src/components/states/emptyState/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export { EmptyState } from './emptyState';
export type {
IEmptyStateBaseProps,
IEmptyStateButton,
IEmptyStateHumanIllustrationProps,
IEmptyStateObjectIllustrationProps,
IEmptyStateProps,
} from './emptyState.api';
1 change: 1 addition & 0 deletions src/components/states/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './emptyState';

0 comments on commit 7d35e4b

Please sign in to comment.