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 `