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
66 changes: 66 additions & 0 deletions src/tedi/components/misc/empty-state/empty-state.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
.tedi-empty-state {
display: flex;
flex-direction: column;
gap: var(--empty-state-inner-spacing-y-lg);
align-items: center;
justify-content: center;
width: 100%;
padding: var(--empty-state-padding);
background: var(--empty-state-background-primary);
border: 1px solid var(--empty-state-border);
border-radius: var(--empty-state-radius);
}

.tedi-empty-state--small {
padding: var(--empty-state-padding-sm);
}

.tedi-empty-state--attached {
border-top: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
}

.tedi-empty-state--inside {
background: transparent;
border: 0;
border-radius: 0;
}

.tedi-empty-state__text {
display: flex;
flex-direction: column;
gap: var(--empty-state-inner-spacing-y-md);
align-items: center;
justify-content: center;
width: 100%;
}

.tedi-empty-state__icon {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--empty-state-icon-primary);
}

.tedi-empty-state__content {
display: flex;
flex-direction: column;
gap: var(--empty-state-inner-spacing-y-sm);
align-items: center;
width: 100%;
text-align: center;
}

.tedi-empty-state__heading,
.tedi-empty-state__description {
margin: 0;
}

.tedi-empty-state__actions {
display: flex;
flex-wrap: wrap;
gap: var(--tedi-dimensions-05);
align-items: center;
justify-content: center;
}
93 changes: 93 additions & 0 deletions src/tedi/components/misc/empty-state/empty-state.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { render, screen } from '@testing-library/react';

import { EmptyState } from './empty-state';

import '@testing-library/jest-dom';

describe('EmptyState', () => {
it('renders the description passed as children', () => {
render(<EmptyState>Nothing to see here</EmptyState>);
expect(screen.getByText('Nothing to see here')).toBeInTheDocument();
});

it('renders the default spa icon when no icon prop is provided', () => {
const { container } = render(<EmptyState>Empty</EmptyState>);
const icon = container.querySelector('[data-name="icon"]');
expect(icon).toBeInTheDocument();
expect(icon).toHaveTextContent('spa');
});

it('renders the icon named by a string icon prop', () => {
const { container } = render(<EmptyState icon="event_busy">Empty</EmptyState>);
const icon = container.querySelector('[data-name="icon"]');
expect(icon).toHaveTextContent('event_busy');
});

it('accepts a full IconProps object for the icon', () => {
const { container } = render(<EmptyState icon={{ name: 'inbox', color: 'secondary' }}>Empty</EmptyState>);
const icon = container.querySelector('[data-name="icon"]');
expect(icon).toHaveTextContent('inbox');
});

it('hides the icon when icon is null', () => {
const { container } = render(<EmptyState icon={null}>Empty</EmptyState>);
expect(container.querySelector('[data-name="icon"]')).not.toBeInTheDocument();
});

it('renders a heading as an h3 in brand color', () => {
const { container } = render(<EmptyState heading="Choose new time">You have no data to display</EmptyState>);
const heading = container.querySelector('h3');
expect(heading).toBeInTheDocument();
expect(heading).toHaveTextContent('Choose new time');
});

it('renders the actions slot', () => {
render(<EmptyState actions={<button type="button">Create new</button>}>Empty</EmptyState>);
expect(screen.getByRole('button', { name: 'Create new' })).toBeInTheDocument();
});

it('applies the separate type class by default', () => {
const { container } = render(<EmptyState>Empty</EmptyState>);
const root = container.querySelector('[data-name="tedi-empty-state"]');
expect(root?.className).toMatch(/--separate/);
});

it.each([
['separate', '--separate'],
['attached', '--attached'],
['inside', '--inside'],
] as const)('applies the %s type class', (type, fragment) => {
const { container } = render(<EmptyState type={type}>Empty</EmptyState>);
const root = container.querySelector('[data-name="tedi-empty-state"]');
expect(root?.className).toContain(fragment);
});

it.each([
['default', '--default'],
['small', '--small'],
] as const)('applies the %s size class', (size, fragment) => {
const { container } = render(<EmptyState size={size}>Empty</EmptyState>);
const root = container.querySelector('[data-name="tedi-empty-state"]');
expect(root?.className).toContain(fragment);
});

it('merges a custom className onto the root', () => {
const { container } = render(<EmptyState className="my-empty">Empty</EmptyState>);
expect(container.querySelector('[data-name="tedi-empty-state"]')?.className).toContain('my-empty');
});

it('omits the content wrapper when neither heading nor description is provided', () => {
const { container } = render(<EmptyState icon="spa">{null}</EmptyState>);
const contentDivs = container.querySelectorAll('[class*="tedi-empty-state__content"]');
expect(contentDivs).toHaveLength(0);
});

it('omits the actions wrapper when actions is not provided', () => {
const { container } = render(<EmptyState>Empty</EmptyState>);
expect(container.querySelector('[class*="tedi-empty-state__actions"]')).not.toBeInTheDocument();
});

it('has the expected displayName', () => {
expect(EmptyState.displayName).toBe('EmptyState');
});
});
141 changes: 141 additions & 0 deletions src/tedi/components/misc/empty-state/empty-state.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import type { Meta, StoryObj } from '@storybook/react';

import { Button } from '../../buttons/button/button';
import { Card, CardContent } from '../../cards/card';
import { Link } from '../../navigation/link/link';
import type { EmptyStateProps } from './empty-state';
import { EmptyState } from './empty-state';

/**
* EmptyState communicates that there is nothing to display — empty search
* results, an unpopulated list, a freshly-created workspace — and optionally
* guides the user toward the next step via action buttons or a link.
*
* <a href="https://www.figma.com/design/jWiRIXhHRxwVdMSimKX2FF/TEDI-READY-2.45.70?node-id=2413-40492&m=dev" target="_BLANK">Figma ↗</a><br />
* <a href="https://www.tedi.ee/1ee8444b7/p/6792c3-empty-state" target="_BLANK">Zeroheight ↗</a>
*/
const meta: Meta<typeof EmptyState> = {
component: EmptyState,
title: 'TEDI-Ready/Components/Helpers/EmptyState',
argTypes: {
icon: { control: false },
heading: { control: false },
actions: { control: false },
},
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/design/jWiRIXhHRxwVdMSimKX2FF/TEDI-READY-2.45.70?node-id=2413-40492&m=dev',
},
},
};
export default meta;

type Story = StoryObj<EmptyStateProps>;

export const Default: Story = {
args: {
children: 'You have no data to display',
},
};

export const WithPrimaryAction: Story = {
args: {
children: 'You have no data to display',
actions: (
<Button type="button" iconLeft="add">
Create new
</Button>
),
},
};

export const WithSecondaryAction: Story = {
args: {
children: 'You have no data to display',
actions: (
<Button type="button" visualType="secondary" iconLeft="add">
Create new
</Button>
),
},
};

export const WithLink: Story = {
args: {
children: 'You have no data to display',
actions: (
<Link href="#" iconRight="arrow_forward">
Read more
</Link>
),
},
};

export const WithHeading: Story = {
args: {
icon: 'event_busy',
heading: 'Choose new time',
children: 'You have no data to display',
actions: <Button type="button">Choose time</Button>,
},
};

export const Minimal: Story = {
args: {
icon: null,
children: 'You have no data to display',
},
};

export const SmallPadding: Story = {
args: {
children: 'You have no data to display',
size: 'small',
actions: (
<>
<Button type="button" iconLeft="add">
Create new
</Button>
<Button type="button" visualType="secondary" iconRight="arrow_forward">
Read more
</Button>
</>
),
},
};

export const Separate: Story = {
args: {
children: 'You have no data to display',
type: 'separate',
},
};

export const AttachedToComponent: Story = {
render: () => (
<div>
<Card borderRadius={{ bottomLeft: false, bottomRight: false }}>
<CardContent>Previous content</CardContent>
</Card>
<EmptyState type="attached">You have no data to display</EmptyState>
</div>
),
};

export const InsideComponent: Story = {
render: () => (
<Card>
<CardContent>
<EmptyState type="inside">You have no data to display</EmptyState>
</CardContent>
</Card>
),
};

export const CustomIcon: Story = {
args: {
children: 'No products in your cart',
icon: { name: 'shopping_cart_off' },
},
};
Loading
Loading