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
69 changes: 69 additions & 0 deletions src/components/empty/Empty.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Upload } from '@phosphor-icons/react';
import { ReactNode } from 'react';
import { Button } from '../button/Button';

interface EmptyProps {
icon: JSX.Element;
title: string;
subtitle: string;
action?: {
text: string;
icon: typeof Upload;
style: 'plain' | 'elevated';
onClick: () => void;
};
contextMenuClick?: (event: React.MouseEvent<HTMLDivElement>) => void;
}

/**
* Empty component
*
* This component is used to display a message or placeholder content when there is no data or items available.
* It allows for a customizable icon, title, subtitle, and an optional action button.
*
* @property {JSX.Element} icon
* - The icon to be displayed at the top of the component. This can be any valid React element.
*
* @property {string} title
* - The main title or heading to be displayed in the component.
*
* @property {string} subtitle
* - A secondary subtitle or description.
*
* @property {object} [action]
* - An optional object containing details for an action button that can be displayed.
*
* @property {string} action.text
* - The text to display on the action button.
*
* @property {Function} [contextMenuClick]
* - An optional function to handle right-click (context menu) interactions on the component.
*/

const Empty = ({ icon, title, subtitle, action, contextMenuClick }: EmptyProps): JSX.Element => {
let button: ReactNode = null;

if (action) {
button = (
<Button variant="secondary" onClick={action.onClick}>
<span>{action.text}</span>
<action.icon size={20} />
</Button>
);
}

return (
<div className="h-full w-full p-8" onContextMenu={contextMenuClick}>
<div className="flex h-full flex-col items-center justify-center space-y-6 pb-20">
<div className="pointer-events-none mx-auto w-max">{icon}</div>
<div className="pointer-events-none space-y-1 text-center">
<p className="text-2xl font-medium text-gray-100">{title}</p>
<p className="text-lg text-gray-60">{subtitle}</p>
</div>
{button}
</div>
</div>
);
};

export default Empty;
69 changes: 69 additions & 0 deletions src/components/empty/__test__/Empty.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { Upload } from '@phosphor-icons/react';
import Empty from '../Empty';
import { afterEach, describe, expect, it, vi } from 'vitest';

describe('Empty component', () => {
const mockOnClick = vi.fn();
const mockContextMenu = vi.fn();
const renderEmpty = (props = {}) => {
return render(
<Empty
icon={<Upload data-testid={'upload-icon'} size={48} />}
title="No items available"
subtitle="Please add some items to get started."
action={{
text: 'Upload Files',
icon: Upload,
style: 'elevated',
onClick: mockOnClick,
}}
contextMenuClick={mockContextMenu}
{...props}
/>,
);
};
afterEach(() => {
vi.clearAllMocks();
});

it('should match snapshot', () => {
const empty = renderEmpty();
expect(empty).toMatchSnapshot();
});

it('should render the title and subtitle', () => {
const { getByText } = renderEmpty();

expect(getByText('No items available')).toBeInTheDocument();
expect(getByText('Please add some items to get started.')).toBeInTheDocument();
});

it('should render action button and trigger onClick', () => {
const { getByText } = renderEmpty();
expect(getByText('Upload Files')).toBeInTheDocument();
fireEvent.click(getByText('Upload Files'));

expect(mockOnClick).toHaveBeenCalledTimes(1);
});

it('should not render action button if no action is passed', () => {
const { queryByText } = renderEmpty({ action: undefined });

const button = queryByText('Upload Files');
expect(button).not.toBeInTheDocument();
});

it('should handle context menu click', () => {
const { getByText } = renderEmpty();
fireEvent.contextMenu(getByText('No items available'));

expect(mockContextMenu).toHaveBeenCalledTimes(1);
});

it('should render the icon', () => {
const { getByTestId } = renderEmpty();
expect(getByTestId('upload-icon')).toBeInTheDocument();
});
});
192 changes: 192 additions & 0 deletions src/components/empty/__test__/__snapshots__/Empty.test.tsx.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`Empty component > should match snapshot 1`] = `
{
"asFragment": [Function],
"baseElement": <body>
<div>
<div
class="h-full w-full p-8"
>
<div
class="flex h-full flex-col items-center justify-center space-y-6 pb-20"
>
<div
class="pointer-events-none mx-auto w-max"
>
<svg
data-testid="upload-icon"
fill="currentColor"
height="48"
viewBox="0 0 256 256"
width="48"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M240,136v64a16,16,0,0,1-16,16H32a16,16,0,0,1-16-16V136a16,16,0,0,1,16-16H80a8,8,0,0,1,0,16H32v64H224V136H176a8,8,0,0,1,0-16h48A16,16,0,0,1,240,136ZM85.66,77.66,120,43.31V128a8,8,0,0,0,16,0V43.31l34.34,34.35a8,8,0,0,0,11.32-11.32l-48-48a8,8,0,0,0-11.32,0l-48,48A8,8,0,0,0,85.66,77.66ZM200,168a12,12,0,1,0-12,12A12,12,0,0,0,200,168Z"
/>
</svg>
</div>
<div
class="pointer-events-none space-y-1 text-center"
>
<p
class="text-2xl font-medium text-gray-100"
>
No items available
</p>
<p
class="text-lg text-gray-60"
>
Please add some items to get started.
</p>
</div>
<button
class="h-10 px-5 relative flex shrink-0 select-none flex-row items-center justify-center space-x-2
whitespace-nowrap rounded-lg text-base font-medium outline-none ring-2 ring-primary/0
ring-offset-2 ring-offset-transparent transition-all duration-100 ease-in-out
focus-visible:ring-primary/50 bg-surface dark:bg-gray-5 border border-gray-10 hover:border-gray-20 active:bg-gray-1 dark:active:bg-gray-10 text-gray-80 shadow-sm "
type="button"
>
<div
class="flex items-center justify-center space-x-2"
>
<span>
Upload Files
</span>
<svg
fill="currentColor"
height="20"
viewBox="0 0 256 256"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M240,136v64a16,16,0,0,1-16,16H32a16,16,0,0,1-16-16V136a16,16,0,0,1,16-16H80a8,8,0,0,1,0,16H32v64H224V136H176a8,8,0,0,1,0-16h48A16,16,0,0,1,240,136ZM85.66,77.66,120,43.31V128a8,8,0,0,0,16,0V43.31l34.34,34.35a8,8,0,0,0,11.32-11.32l-48-48a8,8,0,0,0-11.32,0l-48,48A8,8,0,0,0,85.66,77.66ZM200,168a12,12,0,1,0-12,12A12,12,0,0,0,200,168Z"
/>
</svg>
</div>
</button>
</div>
</div>
</div>
</body>,
"container": <div>
<div
class="h-full w-full p-8"
>
<div
class="flex h-full flex-col items-center justify-center space-y-6 pb-20"
>
<div
class="pointer-events-none mx-auto w-max"
>
<svg
data-testid="upload-icon"
fill="currentColor"
height="48"
viewBox="0 0 256 256"
width="48"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M240,136v64a16,16,0,0,1-16,16H32a16,16,0,0,1-16-16V136a16,16,0,0,1,16-16H80a8,8,0,0,1,0,16H32v64H224V136H176a8,8,0,0,1,0-16h48A16,16,0,0,1,240,136ZM85.66,77.66,120,43.31V128a8,8,0,0,0,16,0V43.31l34.34,34.35a8,8,0,0,0,11.32-11.32l-48-48a8,8,0,0,0-11.32,0l-48,48A8,8,0,0,0,85.66,77.66ZM200,168a12,12,0,1,0-12,12A12,12,0,0,0,200,168Z"
/>
</svg>
</div>
<div
class="pointer-events-none space-y-1 text-center"
>
<p
class="text-2xl font-medium text-gray-100"
>
No items available
</p>
<p
class="text-lg text-gray-60"
>
Please add some items to get started.
</p>
</div>
<button
class="h-10 px-5 relative flex shrink-0 select-none flex-row items-center justify-center space-x-2
whitespace-nowrap rounded-lg text-base font-medium outline-none ring-2 ring-primary/0
ring-offset-2 ring-offset-transparent transition-all duration-100 ease-in-out
focus-visible:ring-primary/50 bg-surface dark:bg-gray-5 border border-gray-10 hover:border-gray-20 active:bg-gray-1 dark:active:bg-gray-10 text-gray-80 shadow-sm "
type="button"
>
<div
class="flex items-center justify-center space-x-2"
>
<span>
Upload Files
</span>
<svg
fill="currentColor"
height="20"
viewBox="0 0 256 256"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M240,136v64a16,16,0,0,1-16,16H32a16,16,0,0,1-16-16V136a16,16,0,0,1,16-16H80a8,8,0,0,1,0,16H32v64H224V136H176a8,8,0,0,1,0-16h48A16,16,0,0,1,240,136ZM85.66,77.66,120,43.31V128a8,8,0,0,0,16,0V43.31l34.34,34.35a8,8,0,0,0,11.32-11.32l-48-48a8,8,0,0,0-11.32,0l-48,48A8,8,0,0,0,85.66,77.66ZM200,168a12,12,0,1,0-12,12A12,12,0,0,0,200,168Z"
/>
</svg>
</div>
</button>
</div>
</div>
</div>,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"rerender": [Function],
"unmount": [Function],
}
`;
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export * from './avatar/Avatar';
export * from './spinner/Spinner';
export * from './slider/RangeSlider';
export * from './dialog/Dialog';
export * from './empty/Empty';
export * from './modal/Modal';
export * from './infiniteScroll/InfiniteScroll';
export * from './contextMenu/ContextMenu';
Expand Down
44 changes: 44 additions & 0 deletions src/stories/components/empty/Empty.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Meta, StoryObj } from '@storybook/react';
import { Upload } from '@phosphor-icons/react';
import Empty from '../../../components/empty/Empty';

const meta: Meta<typeof Empty> = {
title: 'Components/Empty',
component: Empty,
argTypes: {
action: {
control: 'object',
description: 'Action button configuration',
},
contextMenuClick: {
action: 'contextMenuClick',
description: 'Handles right-click interactions',
},
},
};

export default meta;

type Story = StoryObj<typeof Empty>;

export const Default: Story = {
args: {
icon: <Upload size={48} />,
title: 'No Items Available',
subtitle: 'Please add some items to get started.',
},
};

export const WithAction: Story = {
args: {
icon: <Upload size={48} />,
title: 'Upload New Files',
subtitle: 'Drag and drop files here or use the button below.',
action: {
text: 'Upload Files',
icon: Upload,
style: 'elevated',
onClick: () => alert('Uploading files...'),
},
},
};