From a69e136dfff844f56bb73413aaee70809b856366 Mon Sep 17 00:00:00 2001
From: Airike Jaska <95303654+airikej@users.noreply.github.com>
Date: Wed, 22 Apr 2026 14:44:09 +0300
Subject: [PATCH 1/4] feat(empty-state): new TEDI-Ready component #10
---
.../empty-state/empty-state.module.scss | 66 +++++++++
.../empty-state/empty-state.spec.tsx | 108 ++++++++++++++
.../empty-state/empty-state.stories.tsx | 140 ++++++++++++++++++
.../notifications/empty-state/empty-state.tsx | 111 ++++++++++++++
.../notifications/empty-state/index.ts | 2 +
src/tedi/index.ts | 1 +
6 files changed, 428 insertions(+)
create mode 100644 src/tedi/components/notifications/empty-state/empty-state.module.scss
create mode 100644 src/tedi/components/notifications/empty-state/empty-state.spec.tsx
create mode 100644 src/tedi/components/notifications/empty-state/empty-state.stories.tsx
create mode 100644 src/tedi/components/notifications/empty-state/empty-state.tsx
create mode 100644 src/tedi/components/notifications/empty-state/index.ts
diff --git a/src/tedi/components/notifications/empty-state/empty-state.module.scss b/src/tedi/components/notifications/empty-state/empty-state.module.scss
new file mode 100644
index 000000000..b887ead5c
--- /dev/null
+++ b/src/tedi/components/notifications/empty-state/empty-state.module.scss
@@ -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;
+}
diff --git a/src/tedi/components/notifications/empty-state/empty-state.spec.tsx b/src/tedi/components/notifications/empty-state/empty-state.spec.tsx
new file mode 100644
index 000000000..4bf1ae8af
--- /dev/null
+++ b/src/tedi/components/notifications/empty-state/empty-state.spec.tsx
@@ -0,0 +1,108 @@
+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(Nothing to see here);
+ expect(screen.getByText('Nothing to see here')).toBeInTheDocument();
+ });
+
+ it('renders the default spa icon when no icon prop is provided', () => {
+ const { container } = render(Empty);
+ 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(Empty);
+ const icon = container.querySelector('[data-name="icon"]');
+ expect(icon).toHaveTextContent('event_busy');
+ });
+
+ it('accepts a full IconProps object for the icon', () => {
+ const { container } = render(Empty);
+ const icon = container.querySelector('[data-name="icon"]');
+ expect(icon).toHaveTextContent('inbox');
+ });
+
+ it('renders an arbitrary ReactNode icon', () => {
+ render(
+
+
+
+ }
+ >
+ Empty
+
+ );
+ expect(screen.getByLabelText('custom-illustration')).toBeInTheDocument();
+ });
+
+ it('hides the icon when icon is null', () => {
+ const { container } = render(Empty);
+ expect(container.querySelector('[data-name="icon"]')).not.toBeInTheDocument();
+ });
+
+ it('renders a heading as an h3 in brand color', () => {
+ const { container } = render(You have no data to display);
+ const heading = container.querySelector('h3');
+ expect(heading).toBeInTheDocument();
+ expect(heading).toHaveTextContent('Choose new time');
+ });
+
+ it('renders the actions slot', () => {
+ render(Create new}>Empty);
+ expect(screen.getByRole('button', { name: 'Create new' })).toBeInTheDocument();
+ });
+
+ it('applies the separate type class by default', () => {
+ const { container } = render(Empty);
+ 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(Empty);
+ 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(Empty);
+ 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(Empty);
+ 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({null});
+ 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(Empty);
+ expect(container.querySelector('[class*="tedi-empty-state__actions"]')).not.toBeInTheDocument();
+ });
+
+ it('has the expected displayName', () => {
+ expect(EmptyState.displayName).toBe('EmptyState');
+ });
+});
diff --git a/src/tedi/components/notifications/empty-state/empty-state.stories.tsx b/src/tedi/components/notifications/empty-state/empty-state.stories.tsx
new file mode 100644
index 000000000..521520bc1
--- /dev/null
+++ b/src/tedi/components/notifications/empty-state/empty-state.stories.tsx
@@ -0,0 +1,140 @@
+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.
+ *
+ * Figma ↗
+ * Zeroheight ↗
+ */
+const meta: Meta = {
+ component: EmptyState,
+ title: 'TEDI-Ready/Components/Helpers/EmptyState',
+ 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;
+
+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: (
+
+ ),
+ },
+};
+
+export const WithSecondaryAction: Story = {
+ args: {
+ children: 'You have no data to display',
+ actions: (
+
+ ),
+ },
+};
+
+export const WithLink: Story = {
+ args: {
+ children: 'You have no data to display',
+ actions: (
+
+ Read more
+
+ ),
+ },
+};
+
+export const WithHeading: Story = {
+ args: {
+ icon: 'event_busy',
+ heading: 'Choose new time',
+ children: 'You have no data to display',
+ actions: ,
+ },
+};
+
+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: (
+ <>
+
+
+ >
+ ),
+ },
+};
+
+export const Separate: Story = {
+ args: {
+ children: 'You have no data to display',
+ type: 'separate',
+ },
+};
+
+export const AttachedToComponent: Story = {
+ render: () => (
+
+
+ Previous content
+
+ You have no data to display
+
+ ),
+};
+
+export const InsideComponent: Story = {
+ render: () => (
+
+
+ You have no data to display
+
+
+ ),
+};
+
+/**
+ * Any ReactNode can be passed as `icon` — useful when you have a bespoke SVG
+ * or illustration.
+ */
+export const CustomIcon: Story = {
+ args: {
+ children: 'No products in your cart',
+ icon: { name: 'shopping_cart_off' },
+ },
+};
diff --git a/src/tedi/components/notifications/empty-state/empty-state.tsx b/src/tedi/components/notifications/empty-state/empty-state.tsx
new file mode 100644
index 000000000..712be5076
--- /dev/null
+++ b/src/tedi/components/notifications/empty-state/empty-state.tsx
@@ -0,0 +1,111 @@
+import cn from 'classnames';
+import React from 'react';
+
+import { Icon, type IconProps } from '../../base/icon/icon';
+import { Heading } from '../../base/typography/heading/heading';
+import { Text } from '../../base/typography/text/text';
+import styles from './empty-state.module.scss';
+
+export type EmptyStateType = 'separate' | 'attached' | 'inside';
+export type EmptyStateSize = 'default' | 'small';
+
+export interface EmptyStateProps {
+ /**
+ * Container variant — matches the Figma "Types" section.
+ * - `'separate'` (default) — full border + radius, stands on its own.
+ * - `'attached'` — top border omitted so the block sits flush beneath a
+ * preceding card or table (same width + same bottom-radius).
+ * - `'inside'` — no border, no radius; intended to be placed inside another
+ * container such as a `` or `
`.
+ * @default separate
+ */
+ type?: EmptyStateType;
+ /**
+ * Padding scale. `default` = 24px, `small` = 16px.
+ * @default default
+ */
+ size?: EmptyStateSize;
+ /**
+ * Icon rendered above the text block. Pass a Material icon name, a full
+ * `IconProps` object, any React node (e.g. a custom SVG), or `null` to hide
+ * the icon.
+ * @default spa
+ */
+ icon?: string | IconProps | React.ReactNode | null;
+ /**
+ * Optional heading rendered above the description — appears as an H3 in
+ * brand-primary text color.
+ */
+ heading?: React.ReactNode;
+ /**
+ * Main body text describing why there is nothing to show.
+ */
+ children?: React.ReactNode;
+ /**
+ * Call-to-action slot. Typically a `