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
6 changes: 6 additions & 0 deletions .storybook/components/Roadmap/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,4 +356,10 @@ export const rows: Rows = [
stage: '🔵 experimental',
planned: 'Q2 2026',
},
{
component: 'EmptyState',
status: '✅ Done',
stage: '🔵 experimental',
planned: 'Q2 2026',
},
];
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,13 @@
"@chromatic-com/storybook": "^5.1.2",
"@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1",
"@eslint/js": "^9.39.4",
"@internationalized/date": "^3.12.1",
"@koobiq/date-adapter": "^3.5.1",
"@koobiq/date-formatter": "^3.5.1",
"@koobiq/design-tokens": "^3.17.2",
"@koobiq/internationalized-date-adapter": "^3.5.1",
"@koobiq/visuals": "^12.1.0",
"@microsoft/api-extractor": "^7.56.0",
"@rushstack/node-core-library": "^3.66.1",
"@storybook/addon-a11y": "^10.3.6",
Expand All @@ -58,7 +60,6 @@
"@storybook/addon-mcp": "^0.6.0",
"@storybook/react": "^10.3.6",
"@storybook/react-vite": "^10.3.6",
"@eslint/js": "^9.39.4",
"@stylistic/eslint-plugin": "^5.10.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
Expand All @@ -72,8 +73,8 @@
"@vueless/storybook-dark-mode": "^10.0.8",
"browserslist": "^4.28.1",
"browserslist-to-esbuild": "^2.1.1",
"chalk": "^4.1.2",
"bumpp": "^10.1.0",
"chalk": "^4.1.2",
"commit-and-tag-version": "^12.5.0",
"dotenv": "^16.4.7",
"eslint": "^9.39.4",
Expand Down
101 changes: 101 additions & 0 deletions packages/components/src/components/EmptyState/EmptyState.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import {
Meta,
Story,
Props,
Status,
} from '../../../../../.storybook/components';

import * as Stories from './EmptyState.stories';

<Meta of={Stories} />

# EmptyState

<Status variant="experimental" />

EmptyState communicates that there is no data to display and, optionally,
suggests the next action the user can take.

## Import

```tsx
import { EmptyState } from '@koobiq/react-components';
```

## Usage

<Story of={Stories.Base} />

## Props

<Props of={Stories.Base} />

## Anatomy

The component is composed of four optional slots. Render only the ones you need.

```tsx
<EmptyState>
<EmptyState.Media />
<EmptyState.Title />
<EmptyState.Content />
<EmptyState.Actions />
</EmptyState>
```

- `EmptyState.Media` — an illustration or icon.
- `EmptyState.Title` — the heading.
- `EmptyState.Content` — the supporting text.
- `EmptyState.Actions` — buttons, links or pseudo-links.

## Size

Use the `size` prop to switch between `big`, `normal` (default) and `compact`.
The size adjusts the typography, paddings and the max width of the content.

<Story of={Stories.Sizes} />

## Invalid

Set the `isInvalid` prop to paint the title, text and media with the error color —
for example, when data failed to load.

<Story of={Stories.Invalid} />

## Alignment

Use the `align` prop to align the content to the `start` or `center` (default)
of the available block space.

<Story of={Stories.Align} />

## With illustration

Put any visual into `EmptyState.Media` — an icon, an `IconItem` or a raster
illustration. The example below uses the illustrations from the
[`@koobiq/visuals`](https://www.npmjs.com/package/@koobiq/visuals) package and
switches the `light` / `dark` asset to match the active theme.

<Story of={Stories.WithIllustration} />

## Text only

Every slot is optional. Drop the media and actions for a minimal, text-only
empty state.

<Story of={Stories.TextOnly} />

## Accessibility

- If the empty state appears dynamically (e.g. after a search returns nothing),
add `role="status"` so screen readers announce it:

```tsx
<EmptyState role="status">
<EmptyState.Title>Nothing found</EmptyState.Title>
<EmptyState.Content>Try a different search query.</EmptyState.Content>
</EmptyState>
```

- `EmptyState.Title` is an `h3` by default — use `as` to fit the page headings.
- Add `alt` to illustrations, or `alt=""` if they are decorative.
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
.base {
box-sizing: border-box;
display: flex;
flex: 1 1 auto;
flex-direction: column;
align-items: center;
inline-size: 100%;
text-align: center;
}

/* align */
.center {
justify-content: center;
}

.start {
justify-content: flex-start;
}

/* size */
.big {
padding-block: var(--kbq-size-5xl);
padding-inline: var(--kbq-size-6xl);
}

.normal,
.compact {
padding: var(--kbq-size-3xl);
}
168 changes: 168 additions & 0 deletions packages/components/src/components/EmptyState/EmptyState.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { IconBell16, IconTriangleExclamation16 } from '@koobiq/react-icons';
import emptyDark from '@koobiq/visuals/dark/empty_256.webp';
import emptyDark2x from '@koobiq/visuals/dark/empty_256@2x.webp';
import emptyLight from '@koobiq/visuals/light/empty_256.webp';
import emptyLight2x from '@koobiq/visuals/light/empty_256@2x.webp';
import type { Meta, StoryObj } from '@storybook/react';
import { useDarkMode } from '@vueless/storybook-dark-mode';

import { Button } from '../Button';
import { FlexBox } from '../FlexBox';
import { IconItem } from '../IconItem';
import { Link } from '../Link';
import { Typography } from '../Typography';

import { EmptyState } from './index';

const meta = {
title: 'Components/EmptyState',
component: EmptyState,
subcomponents: {
'EmptyState.Media': EmptyState.Media,
'EmptyState.Title': EmptyState.Title,
'EmptyState.Content': EmptyState.Content,
'EmptyState.Actions': EmptyState.Actions,
},
tags: ['status:new', 'date:2026-06-26'],
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof EmptyState>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Base: Story = {
render: (args) => (
<EmptyState {...args}>
<EmptyState.Media>
<IconItem size="big" color="contrast" variant="fade">
<IconBell16 />
</IconItem>
</EmptyState.Media>
<EmptyState.Title>No documents yet</EmptyState.Title>
<EmptyState.Content>
Create your first document to get started, or import an existing one.
</EmptyState.Content>
<EmptyState.Actions>
<Button variant="contrast-filled">Create</Button>
<Button variant="fade-contrast-outline">Import</Button>
</EmptyState.Actions>
</EmptyState>
),
};

export const Sizes: Story = {
parameters: { layout: 'padded' },
render: (args) => (
<FlexBox
gap="3xl"
alignItems="flex-start"
justifyContent="center"
wrap="wrap"
>
{(['big', 'normal', 'compact'] as const).map((size) => (
<EmptyState key={size} {...args} size={size}>
<EmptyState.Media>
<IconItem size="big" color="contrast" variant="fade">
<IconBell16 />
</IconItem>
</EmptyState.Media>
<EmptyState.Title>No documents yet</EmptyState.Title>
<EmptyState.Content>
Create your first document to get started.
</EmptyState.Content>
<EmptyState.Actions>
<Button variant="contrast-filled">Create</Button>
</EmptyState.Actions>
</EmptyState>
))}
</FlexBox>
),
};

export const Invalid: Story = {
render: (args) => (
<EmptyState isInvalid {...args}>
<EmptyState.Media>
<IconItem size="big" color="error" variant="fade">
<IconTriangleExclamation16 />
</IconItem>
</EmptyState.Media>
<EmptyState.Title>Failed to load documents</EmptyState.Title>
<EmptyState.Content>
Something went wrong while loading the data. Please try again.
</EmptyState.Content>
<EmptyState.Actions>
<Button variant="contrast-filled">Retry</Button>
</EmptyState.Actions>
</EmptyState>
),
};

export const Align: Story = {
parameters: { layout: 'padded' },
render: (args) => (
<EmptyState align="start" style={{ blockSize: 360 }} {...args}>
<EmptyState.Media>
<IconItem size="big" color="contrast" variant="fade">
<IconBell16 />
</IconItem>
</EmptyState.Media>
<EmptyState.Title>No documents yet</EmptyState.Title>
<EmptyState.Content>
The content is aligned to the start of the available space.
</EmptyState.Content>
</EmptyState>
),
};

export const WithIllustration: Story = {
render: function Render(args) {
const isDark = useDarkMode();

const src = isDark ? emptyDark : emptyLight;
const src2x = isDark ? emptyDark2x : emptyLight2x;

return (
<EmptyState size="big" {...args}>
<EmptyState.Media>
<img
src={src}
srcSet={`${src} 1x, ${src2x} 2x`}
width={256}
height={256}
alt="No documents"
/>
</EmptyState.Media>
<EmptyState.Title>No documents yet</EmptyState.Title>
<EmptyState.Content style={{ whiteSpace: 'pre-line' }}>
{
'Create your first document to get started,\nor import an existing one.'
}
</EmptyState.Content>
<EmptyState.Actions>
<Button variant="contrast-filled">Create</Button>
</EmptyState.Actions>
</EmptyState>
);
},
};

export const TextOnly: Story = {
render: (args) => (
<EmptyState {...args}>
<EmptyState.Title>Nothing found</EmptyState.Title>
<EmptyState.Content style={{ whiteSpace: 'pre-line' }}>
<Typography as="span" color="inherit" variant="inherit" display="block">
Try changing the search query or
</Typography>
<Link href="#" isPseudo>
reset the filters
</Link>
.
</EmptyState.Content>
</EmptyState>
),
};
Loading
Loading