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(EmptyState): add EmptyState component #1491

Merged
merged 15 commits into from
Jan 23, 2025
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
42 changes: 42 additions & 0 deletions packages/react-components/src/components/EmptyState/EmptyState.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Canvas, Controls, Meta, Title } from '@storybook/blocks';

import * as EmptyStateStories from './EmptyState.stories';

<Meta of={EmptyStateStories} />

<Title>EmptyState</Title>

[Intro](#Intro) | [Component API](#ComponentAPI) | [Content Spec](#ContentSpec)

## Intro <a id="Intro" />

The Empty State component is used to display a placeholder when there is no content to show in a container or page.
It helps communicate to users why content is missing and what actions they can take,
providing a better user experience than showing a blank space.

<Canvas of={EmptyStateStories.Default} sourceState="none" />

#### Example implementation

```jsx
<EmptyState
icon={InfoIcon}
title="No data"
description="There is no data to display"
actions={<Button kind="primary">Go to settings</Button>}
/>
```

## Component API <a id="ComponentAPI" />

<Controls of={EmptyStateStories.Default} sort="requiredFirst" />

## Content Spec <a id="ContentSpec" />

<a
className="sb-unstyled"
href="https://www.figma.com/design/9FDwjR8lYvincseDkKypC4/%5BDS%5D-Component-Documentations?node-id=24836-28755&t=XiUCfb1mKh8KKhcQ-0"
target="_blank"
>
Go to Figma documentation
</a>
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
$base-class: 'empty-state';

.#{$base-class} {
display: flex;
align-items: center;

&--centered {
justify-content: center;
width: 100%;
height: 100%;
}

&--inline {
flex-direction: row;
justify-content: space-between;
}

&--full {
flex-direction: column;
justify-content: center;
padding: var(--spacing-16) var(--spacing-6);

@media (width <= 600px) {
padding: var(--spacing-8) var(--spacing-6);
}
}

&__image {
margin-bottom: var(--spacing-6);
border-radius: var(--radius-4);
width: auto;
max-width: min(100%, 600px);
height: auto;
max-height: min(100%, 300px);
object-fit: contain;
}

&__icon {
&--full {
margin-bottom: var(--spacing-2);
}
}

&__description {
max-width: 600px;
text-align: center;
}

&__content-inline {
display: flex;
gap: var(--spacing-2);
align-items: center;
}

&__title {
margin: 0;
margin-bottom: var(--spacing-2);
max-width: 600px;
text-align: center;
}

&__actions {
display: flex;
gap: var(--spacing-2);
margin-top: var(--spacing-4);

&--inline {
margin-top: 0;
}

@media (width <= 600px) {
flex-direction: column;
margin-top: var(--spacing-2);

&--inline {
margin-top: 0;
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Info as InfoIcon } from '@livechat/design-system-icons';

import { render } from 'test-utils';

import { Button } from '../Button';
import { Icon } from '../Icon';

import { EmptyState } from './EmptyState';
import { IEmptyStateProps } from './types';

const renderComponent = (props: IEmptyStateProps) => {
return render(<EmptyState {...props} />);
};

describe('<EmptyState> component', () => {
it('should render with image', () => {
const { getByAltText } = renderComponent({
title: 'Test title',
description: 'Test description',
image: 'test-image.jpg',
});

const image = getByAltText('Test title');

expect(image).toBeVisible();
expect(image).toHaveAttribute('src', 'test-image.jpg');
});

it('should render actions when provided', () => {
const { getByText } = renderComponent({
title: 'Test title',
description: 'Test description',
actions: <Button>Test action</Button>,
});

expect(getByText('Test action')).toBeVisible();
});

it('should render with icon', () => {
const { getByTestId } = renderComponent({
title: 'Test title',
description: 'Test description',
icon: <Icon source={InfoIcon} />,
});

const icon = getByTestId('icon');

expect(icon).toBeVisible();
});
});
JoannaSikora marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
.empty-state-story {
&--centered {
height: 100vh;
}

&__custom-content {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
align-items: center;

&__row {
display: flex;
gap: var(--spacing-2);

@media (width <= 600px) {
flex-direction: column;
}
}

&__box {
border-radius: 8px;
background-color: var(--surface-secondary-default);
padding: var(--spacing-6);
width: 220px;
}

@media (width <= 600px) {
flex-direction: column;
}
}

&__icon {
svg {
width: 40px;
height: 40px;
color: var(--content-basic-disabled);
}
}

&__action-menu {
width: 200px;

&__item {
width: 400px;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import {
Info as InfoIcon,
FacebookColored as FacebookIcon,
GoogleLightModeColored as GoogleIcon,
MoreHoriz,
} from '@livechat/design-system-icons';
import { Meta, StoryObj } from '@storybook/react';

import { ActionMenu } from '../ActionMenu';
import { Button } from '../Button';
import { Icon } from '../Icon';
import { ListItem } from '../ListItem';
import { Text } from '../Typography';

import { EmptyState } from './EmptyState';

import styles from './EmptyState.stories.module.scss';

export default {
title: 'Components/EmptyState',
component: EmptyState,
} as Meta<typeof EmptyState>;

type Story = StoryObj<typeof EmptyState>;

export const Default: Story = {
args: {
image: 'https://placehold.co/600x300',
title: 'No data',
description: 'There is no data to display',
actions: (
<>
<Button kind="primary">Primary action</Button>
<Button kind="secondary">Secondary action</Button>
<Button icon={<Icon source={InfoIcon} />} kind="secondary">
Tell me more
</Button>
<Button kind="link">Link action</Button>
</>
),
},
};

export const Inline: Story = {
decorators: [
(Story) => (
<div className={styles['empty-state-story__action-menu']}>
<ActionMenu
options={[
{
key: '1',
element: (
<div className={styles['empty-state-story__action-menu__item']}>
<ListItem>
<Story />
</ListItem>
</div>
),
},
]}
triggerRenderer={
<Button icon={<Icon source={MoreHoriz} kind="primary" />} />
}
openedOnInit
></ActionMenu>
</div>
),
],
args: {
icon: <Icon source={InfoIcon} />,
title: 'No data',
type: 'inline',
actions: <Button kind="link">Plain action</Button>,
},
parameters: {
controls: {
exclude: ['description', 'image'],
},
},
};

export const WithIcon: Story = {
args: {
icon: (
<Icon className={styles['empty-state-story__icon']} source={InfoIcon} />
),
title: 'No data',
description: 'There is no data to display',
actions: (
<>
<Button kind="primary">Primary action</Button>
<Button kind="secondary">Secondary action</Button>
</>
),
},
};

export const Centered: Story = {
args: {
centered: true,
icon: (
<Icon className={styles['empty-state-story__icon']} source={InfoIcon} />
),
title: 'No data',
description: 'There is no data to display',
actions: (
<>
<Button kind="primary">Primary action</Button>
<Button kind="secondary">Secondary action</Button>
</>
),
},
decorators: [
(Story) => (
<div className={styles['empty-state-story--centered']}>
<Story />
</div>
),
],
};

export const SmallImage: Story = {
args: {
image: 'https://placehold.co/250x200',
title: 'All tickets solved',
description: 'Follow the instruction to start working with tickets',
},
};

export const VeryBigImage: Story = {
args: {
image: 'https://placehold.co/800x300',
title: 'All tickets solved',
description: 'Follow the instruction to start working with tickets',
},
};

export const WithCustomContentAndNoIllustration: Story = {
args: {
title: 'Title up to 50 characters',
description:
'A description with a maximum of 100 characters. That usually means only one or two sentences.',
actions: (
<div className={styles['empty-state-story__custom-content']}>
<div className={styles['empty-state-story__custom-content__row']}>
<div className={styles['empty-state-story__custom-content__box']}>
<Icon size="xlarge" source={FacebookIcon} />
<Text bold>Facebook Messenger</Text>
</div>
<div className={styles['empty-state-story__custom-content__box']}>
<Icon size="xlarge" source={GoogleIcon} />
<Text bold>Google Messages</Text>
</div>
</div>
<Button kind="link">See all channels</Button>
</div>
),
},
};

export const WithoutActionsOnlyDescription: Story = {
args: {
icon: (
<Icon className={styles['empty-state-story__icon']} source={InfoIcon} />
),
title: 'No data',
description: (
<Text style={{ margin: 0 }}>
There is no data to display{' '}
<Button kind="link"> start by chatting with yourself</Button>
</Text>
),
},
};
Loading
Loading