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
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,16 @@ Use `Dialog` for focused confirmation or interruption. Tones are `primary` and `
- Do not open dialogs for information that can remain inline.
- Do not add a second focus trap, backdrop, escape listener, or portal layer.

### EmptyState

Use `EmptyState` to explain an absence and offer the next useful action.

- Distinguish the reason for absence in the copy: no data yet, no filtered matches, or no configuration.
- Set `headingLevel` to preserve the surrounding document hierarchy.
- Keep the description concrete and within the component's readable measure.
- Provide one focused action when the user can resolve the absence.
- Use Alert for failures; an error is not an empty state.

## Content quality

- Prefer concrete labels: “Create project” over “Submit,” “Delete report” over “Confirm.”
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ All JavaScript entry points ship ESM, CommonJS, and TypeScript declarations. Exp
- `@16-bits-design/ui/button`
- `@16-bits-design/ui/card`
- `@16-bits-design/ui/dialog`
- `@16-bits-design/ui/empty-state`
- `@16-bits-design/ui/input`
- `@16-bits-design/ui/meter`
- `@16-bits-design/ui/select`
Expand All @@ -60,6 +61,23 @@ All JavaScript entry points ship ESM, CommonJS, and TypeScript declarations. Exp
- `@16-bits-design/ui/typography`
- `@16-bits-design/ui/styles.css`

### EmptyState

Use `EmptyState` to explain an absence and offer the next useful action. Descriptions stay within a readable measure, the default pixel motif follows the active theme, and `headingLevel` preserves the surrounding document hierarchy.

```tsx
import { EmptyState } from '@16-bits-design/ui/empty-state';
import { Button } from '@16-bits-design/ui/button';

<EmptyState
title="No deployments yet"
headingLevel={3}
action={<Button size="sm">Create deployment</Button>}
>
Create a deployment to connect a repository and start tracking releases.
</EmptyState>
```

### Spinner

Use `Spinner` for indeterminate waits. Its required label is announced through a polite live region, can remain visible or be visually hidden, and the stepped animation becomes a static bar when reduced motion is requested.
Expand Down
Binary file added docs/evidence/issue-5-empty-state-ember.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/evidence/issue-5-empty-state-ocean.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@
"import": "./dist/dialog.js",
"require": "./dist/dialog.cjs"
},
"./empty-state": {
"types": "./dist/empty-state.d.ts",
"import": "./dist/empty-state.js",
"require": "./dist/empty-state.cjs"
},
"./input": {
"types": "./dist/input.d.ts",
"import": "./dist/input.js",
Expand Down
6 changes: 4 additions & 2 deletions scripts/verify-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ const esm = await import('@16-bits-design/ui');
const esmAlert = await import('@16-bits-design/ui/alert');
const esmCard = await import('@16-bits-design/ui/card');
const esmTheme = await import('@16-bits-design/ui/theme');
const esmEmptyState = await import('@16-bits-design/ui/empty-state');
const esmSpinner = await import('@16-bits-design/ui/spinner');
const esmSegmented = await import('@16-bits-design/ui/segmented');
const esmTable = await import('@16-bits-design/ui/table');

if (esm.Alert !== esmAlert.Alert || esm.Card !== esmCard.Card || esm.Segmented !== esmSegmented.Segmented || esm.Spinner !== esmSpinner.Spinner || esm.ThemeProvider !== esmTheme.ThemeProvider || esm.Table !== esmTable.Table) {
if (esm.Alert !== esmAlert.Alert || esm.Card !== esmCard.Card || esm.EmptyState !== esmEmptyState.EmptyState || esm.Segmented !== esmSegmented.Segmented || esm.Spinner !== esmSpinner.Spinner || esm.ThemeProvider !== esmTheme.ThemeProvider || esm.Table !== esmTable.Table) {
throw new Error('ESM root exports do not resolve to the public component subpaths.');
}

Expand All @@ -34,11 +35,12 @@ const cjs = require('@16-bits-design/ui');
const cjsAlert = require('@16-bits-design/ui/alert');
const cjsCard = require('@16-bits-design/ui/card');
const cjsTheme = require('@16-bits-design/ui/theme');
const cjsEmptyState = require('@16-bits-design/ui/empty-state');
const cjsSpinner = require('@16-bits-design/ui/spinner');
const cjsSegmented = require('@16-bits-design/ui/segmented');
const cjsTable = require('@16-bits-design/ui/table');

if (cjs.Alert !== cjsAlert.Alert || cjs.Card !== cjsCard.Card || cjs.Segmented !== cjsSegmented.Segmented || cjs.Spinner !== cjsSpinner.Spinner || cjs.ThemeProvider !== cjsTheme.ThemeProvider || cjs.Table !== cjsTable.Table) {
if (cjs.Alert !== cjsAlert.Alert || cjs.Card !== cjsCard.Card || cjs.EmptyState !== cjsEmptyState.EmptyState || cjs.Segmented !== cjsSegmented.Segmented || cjs.Spinner !== cjsSpinner.Spinner || cjs.ThemeProvider !== cjsTheme.ThemeProvider || cjs.Table !== cjsTable.Table) {
throw new Error('CommonJS root exports do not resolve to the public component subpaths.');
}

Expand Down
73 changes: 73 additions & 0 deletions src/components/EmptyState.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Button } from './Button';
import { EmptyState } from './EmptyState';

const meta = {
title: 'Components/EmptyState',
component: EmptyState,
tags: ['autodocs'],
args: {
title: 'No deployments yet',
headingLevel: 3,
children: 'Create a deployment to connect a repository and start tracking releases.',
},
argTypes: {
headingLevel: { control: 'inline-radio', options: [2, 3, 4] },
action: { control: false },
illustration: { control: false },
},
} satisfies Meta<typeof EmptyState>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Playground: Story = {
render: (args) => <EmptyState {...args} action={<Button size="sm">Create deployment</Button>} />,
};

export const FilteredResults: Story = {
render: () => (
<EmptyState title="No matching deployments">
No deployments match the current owner and status filters. Adjust the filters to see more results.
</EmptyState>
),
};

export const CustomIllustration: Story = {
render: () => (
<EmptyState
title="Inbox cleared"
illustration={<span className="story-empty-glyph" aria-hidden="true">✓</span>}
action={<Button variant="secondary" size="sm">View archive</Button>}
>
There are no pending review requests. Completed reviews remain available in the archive.
</EmptyState>
),
};

export const InContext: Story = {
render: () => (
<section className="story-section" style={{ width: 'min(100%, 620px)' }}>
<p className="story-label">Deployment history</p>
<EmptyState
title="No deployments yet"
action={<Button size="sm">Create deployment</Button>}
>
Create a deployment to connect a repository and start tracking releases.
</EmptyState>
</section>
),
};

export const NarrowViewport: Story = {
render: () => (
<div style={{ maxWidth: 280 }}>
<EmptyState
title="No matching deployments were found"
action={<Button size="sm" fullWidth>Clear filters</Button>}
>
Adjust the owner and status filters to see deployments from the rest of the workspace.
</EmptyState>
</div>
),
};
55 changes: 55 additions & 0 deletions src/components/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { forwardRef } from 'react';
import type { HTMLAttributes, ReactNode } from 'react';
import { cn } from '../lib/cn';

export type EmptyStateHeadingLevel = 2 | 3 | 4;

export type EmptyStateProps = Omit<HTMLAttributes<HTMLDivElement>, 'title'> & {
title: ReactNode;
headingLevel?: EmptyStateHeadingLevel;
illustration?: ReactNode;
action?: ReactNode;
};

function PixelMotif() {
return (
<span className="bits-empty-state__motif" aria-hidden="true">
<span />
<span />
<span />
</span>
);
}

export const EmptyState = forwardRef<HTMLDivElement, EmptyStateProps>(
(
{
title,
headingLevel = 3,
illustration,
action,
children,
className,
...props
},
ref,
) => {
const Heading = `h${headingLevel}` as const;

return (
<div {...props} ref={ref} className={cn('bits-component bits-empty-state', className)}>
<div className="bits-empty-state__illustration">
{illustration ?? <PixelMotif />}
</div>
<div className="bits-empty-state__copy">
<Heading className="bits-empty-state__title">{title}</Heading>
{children === undefined ? null : (
<div className="bits-empty-state__description">{children}</div>
)}
</div>
{action === undefined ? null : <div className="bits-empty-state__action">{action}</div>}
</div>
);
},
);
EmptyState.displayName = 'EmptyState';
18 changes: 18 additions & 0 deletions src/components/components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Badge } from './Badge';
import { Button } from './Button';
import { Card, CardContent, CardTitle } from './Card';
import { Dialog } from './Dialog';
import { EmptyState } from './EmptyState';
import { Input } from './Field';
import { Meter } from './Meter';
import { Select } from './Select';
Expand Down Expand Up @@ -89,6 +90,23 @@ describe('form controls', () => {
});

describe('display components', () => {
it('renders an empty state with document-aware heading and action', () => {
render(
<EmptyState
title="No deployments yet"
headingLevel={2}
action={<button>Create deployment</button>}
>
Connect a repository to begin.
</EmptyState>,
);

expect(screen.getByRole('heading', { level: 2, name: 'No deployments yet' })).toBeInTheDocument();
expect(screen.getByText('Connect a repository to begin.')).toHaveClass('bits-empty-state__description');
expect(screen.getByRole('button', { name: 'Create deployment' })).toBeEnabled();
expect(document.querySelector('.bits-empty-state__motif')).toHaveAttribute('aria-hidden', 'true');
});

it('announces indeterminate loading with a polite busy status', () => {
render(<Spinner label="Loading deployments" />);
const status = screen.getByRole('status');
Expand Down
2 changes: 2 additions & 0 deletions src/entries/empty-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { EmptyState } from '../components/EmptyState';
export type { EmptyStateHeadingLevel, EmptyStateProps } from '../components/EmptyState';
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ export type { CardProps, CardTone, CardVariant } from '@16-bits-design/ui/card';
export { Dialog } from '@16-bits-design/ui/dialog';
export type { DialogProps, DialogTone } from '@16-bits-design/ui/dialog';

export { EmptyState } from '@16-bits-design/ui/empty-state';
export type { EmptyStateHeadingLevel, EmptyStateProps } from '@16-bits-design/ui/empty-state';

export { Input } from '@16-bits-design/ui/input';
export type { FieldStatus, InputProps } from '@16-bits-design/ui/input';

Expand Down
1 change: 1 addition & 0 deletions src/stories/story.css
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ body { margin: 0; background: var(--bits-surface); color: var(--bits-text); }
.story-subheading { margin: 0; font: 400 13px/1 var(--bits-font-display); }
.story-body { margin: 0; font-size: 12px; }
.story-card { display: flex; flex-direction: column; gap: 8px; min-width: 180px; }
.story-empty-glyph { display: grid; place-items: center; width: 36px; height: 36px; background: var(--bits-success); color: var(--bits-ink); font: 400 13px/1 var(--bits-font-display); }
.story-table-person { display: inline-flex; align-items: center; gap: 8px; white-space: nowrap; }
.story-theme-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); min-height: 360px; }
.story-theme-preview { padding: 32px; background: var(--bits-surface); }
Expand Down
21 changes: 21 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@
80% { transform: translateX(2px); }
}

@keyframes bits-blink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
.bits-reset *,
.bits-reset *::before,
Expand Down Expand Up @@ -375,6 +380,22 @@
.bits-card__header + .bits-card__content { padding-top: 14px; }
.bits-card__footer { display: flex; align-items: center; justify-content: flex-end; gap: 9px; padding: 13px 18px; border-top: var(--bits-border-width) solid var(--bits-line); }

.bits-empty-state { display: flex; align-items: center; justify-content: center; flex-direction: column; gap: var(--bits-space-4); width: 100%; padding: calc(var(--bits-space-6) * 2) var(--bits-space-5); color: var(--bits-text); text-align: center; }
.bits-empty-state__illustration { display: flex; align-items: center; justify-content: center; min-height: 56px; color: var(--bits-primary); }
.bits-empty-state__motif { position: relative; display: block; width: 56px; height: 56px; }
.bits-empty-state__motif span { position: absolute; display: block; }
.bits-empty-state__motif span:nth-child(1) { inset: 0; background: var(--bits-line); }
.bits-empty-state__motif span:nth-child(2) { inset: 6.25%; background: var(--bits-panel); }
.bits-empty-state__motif span:nth-child(3) { top: 62.5%; right: 25%; left: 25%; height: 9.4%; background: var(--bits-line); animation: bits-blink 1.2s steps(1) infinite; }
.bits-empty-state__copy { display: flex; align-items: center; flex-direction: column; gap: var(--bits-space-2); }
.bits-empty-state__title { margin: 0; color: var(--bits-text-soft); font: 400 var(--bits-font-size-h4)/1.2 var(--bits-font-display); }
.bits-empty-state__description { max-width: 46ch; color: var(--bits-text-muted); font-size: var(--bits-font-size-small); line-height: 1.6; overflow-wrap: anywhere; }
.bits-empty-state__action { display: flex; justify-content: center; max-width: 100%; }
@media (max-width: 480px) {
.bits-empty-state { padding: var(--bits-space-6) var(--bits-space-3); }
.bits-empty-state__action { width: 100%; }
}

.bits-table-container { width: 100%; max-width: 100%; overflow-x: auto; border: var(--bits-border-width) solid var(--bits-line); background: var(--bits-panel); color: var(--bits-text); scrollbar-color: var(--bits-line-strong) var(--bits-surface); }
.bits-table-container:focus-visible { outline: none; box-shadow: var(--bits-focus-ring); }
.bits-table { width: 100%; border-spacing: 0; border-collapse: collapse; color: var(--bits-text-soft); font: 400 var(--bits-font-size-body)/1.5 var(--bits-font-body); text-align: start; }
Expand Down
1 change: 1 addition & 0 deletions tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export default defineConfig({
button: 'src/entries/button.ts',
card: 'src/entries/card.ts',
dialog: 'src/entries/dialog.ts',
'empty-state': 'src/entries/empty-state.ts',
input: 'src/entries/input.ts',
meter: 'src/entries/meter.ts',
select: 'src/entries/select.ts',
Expand Down