diff --git a/src/components/empty/Empty.tsx b/src/components/empty/Empty.tsx new file mode 100644 index 0000000..1583246 --- /dev/null +++ b/src/components/empty/Empty.tsx @@ -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) => 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 = ( + + ); + } + + return ( +
+
+
{icon}
+
+

{title}

+

{subtitle}

+
+ {button} +
+
+ ); +}; + +export default Empty; diff --git a/src/components/empty/__test__/Empty.test.tsx b/src/components/empty/__test__/Empty.test.tsx new file mode 100644 index 0000000..c398ded --- /dev/null +++ b/src/components/empty/__test__/Empty.test.tsx @@ -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( + } + 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(); + }); +}); diff --git a/src/components/empty/__test__/__snapshots__/Empty.test.tsx.snap b/src/components/empty/__test__/__snapshots__/Empty.test.tsx.snap new file mode 100644 index 0000000..6aeeb91 --- /dev/null +++ b/src/components/empty/__test__/__snapshots__/Empty.test.tsx.snap @@ -0,0 +1,192 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Empty component > should match snapshot 1`] = ` +{ + "asFragment": [Function], + "baseElement": +
+
+
+
+ + + +
+
+

+ No items available +

+

+ Please add some items to get started. +

+
+ +
+
+
+ , + "container":
+
+
+
+ + + +
+
+

+ No items available +

+

+ Please add some items to get started. +

+
+ +
+
+
, + "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], +} +`; diff --git a/src/components/index.ts b/src/components/index.ts index 400a701..ad42f65 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -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'; diff --git a/src/stories/components/empty/Empty.stories.tsx b/src/stories/components/empty/Empty.stories.tsx new file mode 100644 index 0000000..172d4f3 --- /dev/null +++ b/src/stories/components/empty/Empty.stories.tsx @@ -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 = { + 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; + +export const Default: Story = { + args: { + icon: , + title: 'No Items Available', + subtitle: 'Please add some items to get started.', + }, +}; + +export const WithAction: Story = { + args: { + icon: , + 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...'), + }, + }, +};